Spaces:
Sleeping
Sleeping
Upload 144 files
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- AutoForge/.env.example +33 -0
- AutoForge/.gitignore +13 -0
- AutoForge/Dockerfile +27 -0
- AutoForge/HUGGINGFACE_DEPLOYMENT.md +47 -0
- AutoForge/README.md +212 -0
- AutoForge/backend/__init__.py +0 -0
- AutoForge/backend/agents/__init__.py +1 -0
- AutoForge/backend/agents/__pycache__/__init__.cpython-312.pyc +0 -0
- AutoForge/backend/agents/__pycache__/code_agent.cpython-312.pyc +0 -0
- AutoForge/backend/agents/__pycache__/diagnosis_agent.cpython-312.pyc +0 -0
- AutoForge/backend/agents/__pycache__/planner.cpython-312.pyc +0 -0
- AutoForge/backend/agents/__pycache__/repair_agent.cpython-312.pyc +0 -0
- AutoForge/backend/agents/__pycache__/task_handlers.cpython-312.pyc +0 -0
- AutoForge/backend/agents/__pycache__/test_agent.cpython-312-pytest-9.1.1.pyc +0 -0
- AutoForge/backend/agents/__pycache__/test_agent.cpython-312.pyc +0 -0
- AutoForge/backend/agents/code_agent.py +95 -0
- AutoForge/backend/agents/diagnosis_agent.py +81 -0
- AutoForge/backend/agents/planner.py +91 -0
- AutoForge/backend/agents/repair_agent.py +101 -0
- AutoForge/backend/agents/task_handlers.py +101 -0
- AutoForge/backend/agents/test_agent.py +94 -0
- AutoForge/backend/analysis/__init__.py +1 -0
- AutoForge/backend/analysis/__pycache__/__init__.cpython-312.pyc +0 -0
- AutoForge/backend/analysis/__pycache__/static_analyzer.cpython-312.pyc +0 -0
- AutoForge/backend/analysis/static_analyzer.py +110 -0
- AutoForge/backend/api/__init__.py +0 -0
- AutoForge/backend/api/__pycache__/__init__.cpython-312.pyc +0 -0
- AutoForge/backend/api/__pycache__/auth.cpython-312.pyc +0 -0
- AutoForge/backend/api/__pycache__/providers.cpython-312.pyc +0 -0
- AutoForge/backend/api/__pycache__/schemas.cpython-312.pyc +0 -0
- AutoForge/backend/api/__pycache__/settings.cpython-312.pyc +0 -0
- AutoForge/backend/api/__pycache__/workflows.cpython-312.pyc +0 -0
- AutoForge/backend/api/auth.py +50 -0
- AutoForge/backend/api/providers.py +36 -0
- AutoForge/backend/api/schemas.py +146 -0
- AutoForge/backend/api/settings.py +73 -0
- AutoForge/backend/api/workflows.py +370 -0
- AutoForge/backend/core/__init__.py +0 -0
- AutoForge/backend/core/__pycache__/__init__.cpython-312.pyc +0 -0
- AutoForge/backend/core/__pycache__/config.cpython-312.pyc +0 -0
- AutoForge/backend/core/__pycache__/dependencies.cpython-312.pyc +0 -0
- AutoForge/backend/core/__pycache__/encryption.cpython-312.pyc +0 -0
- AutoForge/backend/core/__pycache__/security.cpython-312.pyc +0 -0
- AutoForge/backend/core/config.py +41 -0
- AutoForge/backend/core/dependencies.py +50 -0
- AutoForge/backend/core/encryption.py +30 -0
- AutoForge/backend/core/security.py +36 -0
- AutoForge/backend/db/__init__.py +0 -0
- AutoForge/backend/db/__pycache__/__init__.cpython-312.pyc +0 -0
- AutoForge/backend/db/__pycache__/session.cpython-312.pyc +0 -0
AutoForge/.env.example
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# AutoForge environment configuration
|
| 2 |
+
# Copy to .env and adjust values for your environment.
|
| 3 |
+
|
| 4 |
+
# PostgreSQL (async SQLAlchemy URL)
|
| 5 |
+
# For Hugging Face Spaces or production, this MUST point to an external PostgreSQL
|
| 6 |
+
# database (e.g., Supabase, Neon, AWS RDS) because Space local disk is ephemeral.
|
| 7 |
+
DATABASE_URL=postgresql+asyncpg://autoforge:autoforge@localhost:5432/autoforge
|
| 8 |
+
|
| 9 |
+
# JWT signing secret (use a long random string in production)
|
| 10 |
+
SECRET_KEY=change-me-in-production-use-a-long-random-string
|
| 11 |
+
|
| 12 |
+
# Fernet-compatible encryption key material for API credential storage
|
| 13 |
+
ENCRYPTION_KEY=change-me-32-byte-base64-fernet-key-here=
|
| 14 |
+
|
| 15 |
+
# JWT algorithm
|
| 16 |
+
ALGORITHM=HS256
|
| 17 |
+
ACCESS_TOKEN_EXPIRE_MINUTES=1440
|
| 18 |
+
|
| 19 |
+
# Logging
|
| 20 |
+
LOG_LEVEL=INFO
|
| 21 |
+
|
| 22 |
+
# Docker sandbox
|
| 23 |
+
DOCKER_HOST=unix:///var/run/docker.sock
|
| 24 |
+
DOCKER_IMAGE=autoforge-sandbox:3.12
|
| 25 |
+
DOCKER_MEMORY_LIMIT=512m
|
| 26 |
+
DOCKER_CPU_LIMIT=1.0
|
| 27 |
+
DOCKER_NETWORK_DISABLED=true
|
| 28 |
+
WORKSPACE_ROOT=/tmp/autoforge/workspaces
|
| 29 |
+
DEFAULT_TIMEOUT=300
|
| 30 |
+
MAX_RETRIES=4
|
| 31 |
+
|
| 32 |
+
# CORS (comma-separated origins)
|
| 33 |
+
CORS_ORIGINS=http://localhost:3000,http://localhost:5173
|
AutoForge/.gitignore
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.env
|
| 2 |
+
.venv/
|
| 3 |
+
__pycache__/
|
| 4 |
+
*.pyc
|
| 5 |
+
.pytest_cache/
|
| 6 |
+
.mypy_cache/
|
| 7 |
+
.ruff_cache/
|
| 8 |
+
*.egg-info/
|
| 9 |
+
dist/
|
| 10 |
+
build/
|
| 11 |
+
frontend/node_modules/
|
| 12 |
+
frontend/dist/
|
| 13 |
+
.tools/
|
AutoForge/Dockerfile
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Use Node.js for building the Vite frontend
|
| 2 |
+
FROM node:20-alpine AS frontend-builder
|
| 3 |
+
WORKDIR /app/frontend
|
| 4 |
+
COPY frontend/package*.json ./
|
| 5 |
+
RUN npm ci
|
| 6 |
+
COPY frontend/ ./
|
| 7 |
+
RUN npm run build
|
| 8 |
+
|
| 9 |
+
# Use Python for the FastAPI backend
|
| 10 |
+
FROM python:3.12-slim
|
| 11 |
+
WORKDIR /app
|
| 12 |
+
|
| 13 |
+
# Install dependencies
|
| 14 |
+
COPY backend/requirements.txt ./
|
| 15 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 16 |
+
|
| 17 |
+
# Copy backend code
|
| 18 |
+
COPY backend/ ./backend/
|
| 19 |
+
|
| 20 |
+
# Copy built frontend from builder stage
|
| 21 |
+
COPY --from=frontend-builder /app/frontend/dist ./frontend/dist
|
| 22 |
+
|
| 23 |
+
# Expose Hugging Face Space port
|
| 24 |
+
EXPOSE 7860
|
| 25 |
+
|
| 26 |
+
# Run FastAPI application on port 7860
|
| 27 |
+
CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "7860"]
|
AutoForge/HUGGINGFACE_DEPLOYMENT.md
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Hugging Face Space Deployment Guide
|
| 2 |
+
|
| 3 |
+
Deploying AutoForge to a public Hugging Face (HF) Space enables anyone to interact with the autonomous DAG-based coding agent.
|
| 4 |
+
|
| 5 |
+
## Architectural Limitation: Docker Sandbox
|
| 6 |
+
|
| 7 |
+
**IMPORTANT:** Hugging Face Docker Spaces run containers in an **unprivileged environment**. They do not support mounting the Docker socket (`/var/run/docker.sock`) or running with `--privileged`.
|
| 8 |
+
|
| 9 |
+
Because AutoForge correctly enforces a strict security boundary using Docker to sandbox LLM-generated code (`DockerCliRunner`), **AutoForge cannot currently run the code execution step natively inside a standard Hugging Face Space.**
|
| 10 |
+
|
| 11 |
+
If Docker is unavailable (as it is inside an HF Space container), the system will safely abort the workflow with `DOCKER_UNAVAILABLE` rather than bypassing the sandbox and risking host compromise.
|
| 12 |
+
|
| 13 |
+
### Recommended Production Architecture
|
| 14 |
+
To fully deploy this to the public while maintaining security, you must decouple the execution environment:
|
| 15 |
+
1. **Hugging Face Space**: Runs the React frontend and FastAPI orchestration backend (using `Dockerfile.huggingface`).
|
| 16 |
+
2. **External Secure Worker (e.g., AWS EC2 or Modal)**: Runs the Docker Daemon. The `DockerCliRunner` should be refactored to communicate with this worker over a secure gRPC or HTTP API rather than relying on local `shutil.which("docker")`.
|
| 17 |
+
|
| 18 |
+
## Deployment Instructions (UI & Orchestration Only)
|
| 19 |
+
|
| 20 |
+
If you wish to deploy the UI and orchestration layers to HF Spaces for demonstration purposes (note that code *execution* tasks will fail safely), follow these steps:
|
| 21 |
+
|
| 22 |
+
### 1. Space Configuration
|
| 23 |
+
- **Space Type:** Docker Space
|
| 24 |
+
- **Hardware:** Blank (CPU is sufficient for orchestration)
|
| 25 |
+
|
| 26 |
+
### 2. External PostgreSQL
|
| 27 |
+
Hugging Face Spaces have ephemeral disks. You **must** use an external PostgreSQL provider (like Supabase, Neon, or AWS RDS) to ensure workflow history and user accounts persist across Space restarts.
|
| 28 |
+
|
| 29 |
+
### 3. Secrets configuration
|
| 30 |
+
In your Hugging Face Space Settings -> Secrets, add the following:
|
| 31 |
+
- `DATABASE_URL`: `postgresql+asyncpg://user:pass@external-host:5432/dbname`
|
| 32 |
+
- `SECRET_KEY`: A long, random string (e.g., generated via `openssl rand -hex 32`).
|
| 33 |
+
- `ENCRYPTION_KEY`: A base64-encoded 32-byte Fernet key for encrypting user LLM API keys.
|
| 34 |
+
|
| 35 |
+
*Users will securely input their own OpenAI/Gemini API keys directly through the React UI.*
|
| 36 |
+
|
| 37 |
+
### 4. Required Files
|
| 38 |
+
Ensure the following structure is committed to your Space repository:
|
| 39 |
+
- `backend/`
|
| 40 |
+
- `frontend/`
|
| 41 |
+
- `Dockerfile` (Rename `Dockerfile.huggingface` to `Dockerfile` for HF Spaces)
|
| 42 |
+
|
| 43 |
+
### 5. Multi-User Safety
|
| 44 |
+
The platform is safe for multi-user public deployment:
|
| 45 |
+
- User API keys are encrypted at rest using `ENCRYPTION_KEY`.
|
| 46 |
+
- The backend enforces `user_id` isolation on all `/workflows/*` endpoints.
|
| 47 |
+
- API keys are injected directly into the LLM adapter at runtime and are never logged.
|
AutoForge/README.md
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# AutoForge (AgentOS)
|
| 2 |
+
|
| 3 |
+
AutoForge is an autonomous DAG-based code generation and verification platform. Users submit natural-language software tasks; the system plans, generates code, tests, analyzes, executes in Docker, verifies, and autonomously repairs failures — with full workflow history persisted in PostgreSQL.
|
| 4 |
+
|
| 5 |
+
**Project Status:** Fully implemented MVP (Phases 1-20 completed).
|
| 6 |
+
|
| 7 |
+
The system supports the full autonomous loop:
|
| 8 |
+
- Frontend React DAG visualization and event streaming
|
| 9 |
+
- Gemini 3.1 Pro LLM adapter for planning and code generation
|
| 10 |
+
- Docker Sandbox execution and verification
|
| 11 |
+
- Automated failure diagnosis and repair
|
| 12 |
+
- Full PostgreSQL state persistence and user isolation
|
| 13 |
+
|
| 14 |
+
## Architecture
|
| 15 |
+
|
| 16 |
+
```mermaid
|
| 17 |
+
flowchart TB
|
| 18 |
+
FE[Frontend] --> API[Backend API]
|
| 19 |
+
API --> ORCH[Workflow Orchestrator]
|
| 20 |
+
ORCH --> PLANNER[Planner LLM]
|
| 21 |
+
ORCH --> MEM[(PostgreSQL)]
|
| 22 |
+
PLANNER --> DAG[DAG Engine]
|
| 23 |
+
DAG --> CODE[Code Agent]
|
| 24 |
+
DAG --> TEST[Test Agent]
|
| 25 |
+
DAG --> ANALYSIS[Static Analysis]
|
| 26 |
+
CODE --> DOCKER[Docker Sandbox]
|
| 27 |
+
TEST --> DOCKER
|
| 28 |
+
ANALYSIS --> DOCKER
|
| 29 |
+
DOCKER --> VERIFY[Verification]
|
| 30 |
+
```
|
| 31 |
+
|
| 32 |
+
Phase 1 implements the **API**, **PostgreSQL memory layer**, **authentication**, **LLM provider abstraction**, and **workflow persistence**.
|
| 33 |
+
|
| 34 |
+
## Repository Structure
|
| 35 |
+
|
| 36 |
+
```text
|
| 37 |
+
AgentOS/
|
| 38 |
+
├── backend/ # FastAPI application
|
| 39 |
+
│ ├── api/ # HTTP routes (auth, workflows, settings)
|
| 40 |
+
│ ├── agents/ # Agent modules (Phase 2+)
|
| 41 |
+
│ ├── orchestration/ # DAG engine (Phase 2+)
|
| 42 |
+
│ ├── execution/ # Docker sandbox (Phase 2+)
|
| 43 |
+
│ ├── analysis/ # Static analysis (Phase 2+)
|
| 44 |
+
│ ├── memory/ # Workflow repository
|
| 45 |
+
│ ├── llm/ # Provider abstraction
|
| 46 |
+
│ ├── db/ # Models and migrations
|
| 47 |
+
│ └── events/ # Event publisher
|
| 48 |
+
├── frontend/ # React + Vite web UI (Phase 5)
|
| 49 |
+
├── benchmark/ # Evaluation suite (Phase 5)
|
| 50 |
+
├── docker/ # Dockerfiles
|
| 51 |
+
├── tests/ # Backend tests
|
| 52 |
+
├── docker-compose.yml
|
| 53 |
+
└── .env.example
|
| 54 |
+
```
|
| 55 |
+
|
| 56 |
+
## Local Setup
|
| 57 |
+
|
| 58 |
+
### Prerequisites
|
| 59 |
+
|
| 60 |
+
- Docker and Docker Compose
|
| 61 |
+
- Python 3.12+ (for local development without Docker)
|
| 62 |
+
|
| 63 |
+
### Quick Start (Docker)
|
| 64 |
+
|
| 65 |
+
```bash
|
| 66 |
+
cp .env.example .env
|
| 67 |
+
docker compose up --build -d
|
| 68 |
+
```
|
| 69 |
+
|
| 70 |
+
### Hugging Face Space Deployment
|
| 71 |
+
For deploying AutoForge to a public Hugging Face Space, please refer to the [Hugging Face Deployment Guide](HUGGINGFACE_DEPLOYMENT.md).
|
| 72 |
+
|
| 73 |
+
Services:
|
| 74 |
+
|
| 75 |
+
| Service | URL |
|
| 76 |
+
|-----------|----------------------------|
|
| 77 |
+
| Frontend | http://localhost:5173 |
|
| 78 |
+
| Backend | http://localhost:8000 |
|
| 79 |
+
| API docs | http://localhost:8000/docs |
|
| 80 |
+
| PostgreSQL| localhost:5432 |
|
| 81 |
+
|
| 82 |
+
Migrations run automatically when the backend container starts.
|
| 83 |
+
|
| 84 |
+
### Local Development (without Docker backend)
|
| 85 |
+
|
| 86 |
+
```bash
|
| 87 |
+
# Start PostgreSQL only
|
| 88 |
+
docker compose up -d postgres
|
| 89 |
+
|
| 90 |
+
# Create virtual environment and install dependencies
|
| 91 |
+
python -m venv .venv
|
| 92 |
+
source .venv/bin/activate # Windows: .venv\Scripts\activate
|
| 93 |
+
pip install -r backend/requirements.txt
|
| 94 |
+
|
| 95 |
+
# Copy and configure environment
|
| 96 |
+
cp .env.example .env
|
| 97 |
+
|
| 98 |
+
# Run migrations
|
| 99 |
+
alembic upgrade head
|
| 100 |
+
|
| 101 |
+
# Start API server
|
| 102 |
+
uvicorn backend.main:app --reload --port 8000
|
| 103 |
+
```
|
| 104 |
+
|
| 105 |
+
### Frontend Development
|
| 106 |
+
|
| 107 |
+
```bash
|
| 108 |
+
cd frontend
|
| 109 |
+
npm install
|
| 110 |
+
npm run dev
|
| 111 |
+
```
|
| 112 |
+
|
| 113 |
+
The Vite dev server runs at http://localhost:5173 and proxies `/api` requests to the backend at http://localhost:8000.
|
| 114 |
+
|
| 115 |
+
For Docker (backend + frontend + PostgreSQL):
|
| 116 |
+
|
| 117 |
+
```bash
|
| 118 |
+
docker compose up --build
|
| 119 |
+
```
|
| 120 |
+
|
| 121 |
+
## Environment Variables
|
| 122 |
+
|
| 123 |
+
See [`.env.example`](.env.example). Key variables:
|
| 124 |
+
|
| 125 |
+
| Variable | Description |
|
| 126 |
+
|----------------|------------------------------------------|
|
| 127 |
+
| `DATABASE_URL` | Async PostgreSQL connection string |
|
| 128 |
+
| `SECRET_KEY` | JWT signing secret |
|
| 129 |
+
| `ENCRYPTION_KEY` | Key material for API credential encryption |
|
| 130 |
+
| `MAX_RETRIES` | Max repair attempts (default: 4) |
|
| 131 |
+
| `DOCKER_HOST` | Docker socket for sandbox (later phases) |
|
| 132 |
+
|
| 133 |
+
Never commit `.env` files with real secrets.
|
| 134 |
+
|
| 135 |
+
## API Endpoints (Phase 1)
|
| 136 |
+
|
| 137 |
+
| Method | Endpoint | Description |
|
| 138 |
+
|--------|---------------------------------|--------------------------|
|
| 139 |
+
| POST | `/auth/signup` | Create account |
|
| 140 |
+
| POST | `/auth/login` | Obtain JWT token |
|
| 141 |
+
| GET | `/auth/me` | Current user |
|
| 142 |
+
| POST | `/workflows` | Create workflow |
|
| 143 |
+
| GET | `/workflows` | List user workflows |
|
| 144 |
+
| GET | `/workflows/{workflow_id}` | Get workflow details |
|
| 145 |
+
| POST | `/workflows/{workflow_id}/start` | Plan, validate, and execute DAG |
|
| 146 |
+
| GET | `/workflows/{workflow_id}/tasks` | List workflow tasks |
|
| 147 |
+
| GET | `/workflows/{workflow_id}/events` | List workflow events (supports `after_id`) |
|
| 148 |
+
| GET | `/workflows/{workflow_id}/events/stream` | SSE live event stream |
|
| 149 |
+
| GET | `/workflows/{workflow_id}/attempts` | List repair/execution attempts |
|
| 150 |
+
| GET | `/settings/providers` | List LLM providers and models |
|
| 151 |
+
| POST | `/settings/api-credentials` | Store encrypted API key |
|
| 152 |
+
| GET | `/settings/api-credentials` | List credentials (redacted) |
|
| 153 |
+
| DELETE | `/settings/api-credentials/{id}` | Delete credential |
|
| 154 |
+
| GET | `/health` | Health + DB connectivity |
|
| 155 |
+
|
| 156 |
+
## Database Schema
|
| 157 |
+
|
| 158 |
+
PostgreSQL tables (Phase 1):
|
| 159 |
+
|
| 160 |
+
- `users` — authentication
|
| 161 |
+
- `workflows` — workflow records with status and planner output
|
| 162 |
+
- `tasks` — DAG task nodes (schema ready; populated in Phase 2)
|
| 163 |
+
- `attempts` — generation/execution attempts
|
| 164 |
+
- `events` — workflow event stream
|
| 165 |
+
- `api_credentials` — encrypted user LLM API keys
|
| 166 |
+
|
| 167 |
+
## LLM Provider Abstraction
|
| 168 |
+
|
| 169 |
+
Providers implement:
|
| 170 |
+
|
| 171 |
+
- `generate()` — text completion
|
| 172 |
+
- `generate_structured()` — JSON output
|
| 173 |
+
- `estimate_cost()` — token cost estimation
|
| 174 |
+
|
| 175 |
+
Currently supported: **OpenAI** and **Gemini**.
|
| 176 |
+
The workflow engine resolves providers through `get_llm_provider()` — no provider-specific code in API routes.
|
| 177 |
+
|
| 178 |
+
Users can securely enter their own API keys (OpenAI or Gemini) directly in the frontend Settings page. The keys are encrypted at rest in the database and automatically injected into the containerized runner during execution.
|
| 179 |
+
|
| 180 |
+
## Running Tests
|
| 181 |
+
|
| 182 |
+
```bash
|
| 183 |
+
# Ensure PostgreSQL is running
|
| 184 |
+
docker compose up -d postgres
|
| 185 |
+
|
| 186 |
+
# Run migrations (if not already applied)
|
| 187 |
+
alembic upgrade head
|
| 188 |
+
|
| 189 |
+
# Install dependencies and run tests
|
| 190 |
+
pip install -r backend/requirements.txt
|
| 191 |
+
pytest -v
|
| 192 |
+
```
|
| 193 |
+
|
| 194 |
+
Tests use a real PostgreSQL connection and truncate tables between test cases.
|
| 195 |
+
|
| 196 |
+
## Security Considerations
|
| 197 |
+
|
| 198 |
+
- Passwords stored as bcrypt hashes
|
| 199 |
+
- API keys encrypted at rest (Fernet-derived key from `ENCRYPTION_KEY`)
|
| 200 |
+
- JWT authentication on all workflow endpoints
|
| 201 |
+
- Users cannot access other users' workflows
|
| 202 |
+
- Generated code execution will run only inside Docker (Phase 2+)
|
| 203 |
+
|
| 204 |
+
## Known Limitations
|
| 205 |
+
|
| 206 |
+
- Docker sandbox requires Docker on the host for real code execution
|
| 207 |
+
- LLM calls require a valid API key (OpenAI supported)
|
| 208 |
+
- Benchmark runner not yet implemented
|
| 209 |
+
|
| 210 |
+
## License
|
| 211 |
+
|
| 212 |
+
See LICENSE (to be added).
|
AutoForge/backend/__init__.py
ADDED
|
File without changes
|
AutoForge/backend/agents/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Agent modules — planner and task handlers."""
|
AutoForge/backend/agents/__pycache__/__init__.cpython-312.pyc
ADDED
|
Binary file (225 Bytes). View file
|
|
|
AutoForge/backend/agents/__pycache__/code_agent.cpython-312.pyc
ADDED
|
Binary file (4.54 kB). View file
|
|
|
AutoForge/backend/agents/__pycache__/diagnosis_agent.cpython-312.pyc
ADDED
|
Binary file (4.38 kB). View file
|
|
|
AutoForge/backend/agents/__pycache__/planner.cpython-312.pyc
ADDED
|
Binary file (4.19 kB). View file
|
|
|
AutoForge/backend/agents/__pycache__/repair_agent.cpython-312.pyc
ADDED
|
Binary file (4.21 kB). View file
|
|
|
AutoForge/backend/agents/__pycache__/task_handlers.cpython-312.pyc
ADDED
|
Binary file (5.11 kB). View file
|
|
|
AutoForge/backend/agents/__pycache__/test_agent.cpython-312-pytest-9.1.1.pyc
ADDED
|
Binary file (4.63 kB). View file
|
|
|
AutoForge/backend/agents/__pycache__/test_agent.cpython-312.pyc
ADDED
|
Binary file (4.52 kB). View file
|
|
|
AutoForge/backend/agents/code_agent.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Any
|
| 2 |
+
|
| 3 |
+
from pydantic import BaseModel, Field
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class GeneratedFile(BaseModel):
|
| 7 |
+
path: str
|
| 8 |
+
content: str
|
| 9 |
+
language: str = "python"
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class CodeGenerationResult(BaseModel):
|
| 13 |
+
files: dict[str, dict[str, str]]
|
| 14 |
+
status: str = "generated"
|
| 15 |
+
|
| 16 |
+
@classmethod
|
| 17 |
+
def from_llm_payload(cls, payload: dict[str, Any]) -> "CodeGenerationResult":
|
| 18 |
+
files: dict[str, dict[str, str]] = {}
|
| 19 |
+
for item in payload.get("files", []):
|
| 20 |
+
path = item["path"].lstrip("/").replace("\\", "/")
|
| 21 |
+
files[path] = {
|
| 22 |
+
"content": item["content"],
|
| 23 |
+
"language": item.get("language", "python"),
|
| 24 |
+
}
|
| 25 |
+
if not files:
|
| 26 |
+
raise ValueError("Code generation produced no files")
|
| 27 |
+
return cls(files=files)
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
CODE_GENERATION_SCHEMA: dict[str, Any] = {
|
| 31 |
+
"type": "object",
|
| 32 |
+
"properties": {
|
| 33 |
+
"files": {
|
| 34 |
+
"type": "array",
|
| 35 |
+
"items": {
|
| 36 |
+
"type": "object",
|
| 37 |
+
"properties": {
|
| 38 |
+
"path": {"type": "string"},
|
| 39 |
+
"content": {"type": "string"},
|
| 40 |
+
"language": {"type": "string"},
|
| 41 |
+
},
|
| 42 |
+
"required": ["path", "content"],
|
| 43 |
+
},
|
| 44 |
+
}
|
| 45 |
+
},
|
| 46 |
+
"required": ["files"],
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
CODE_GENERATION_SYSTEM_PROMPT = """You are a code generation agent.
|
| 50 |
+
Generate a complete, runnable Python project for the user request.
|
| 51 |
+
Return JSON with a files array. Each file must include path, content, and language.
|
| 52 |
+
|
| 53 |
+
Requirements:
|
| 54 |
+
- Use a flat or package layout that pytest can import (e.g. calculator.py or calculator/__init__.py).
|
| 55 |
+
- Include requirements.txt with only third-party packages (pytest is pre-installed in the sandbox).
|
| 56 |
+
- For stdlib-only projects, requirements.txt may be empty or contain only comments.
|
| 57 |
+
- Do not include tests — a separate agent will generate them.
|
| 58 |
+
Return only valid JSON."""
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
from backend.llm.base import LLMProvider
|
| 62 |
+
from backend.orchestration.errors import TaskExecutionError
|
| 63 |
+
from backend.orchestration.task_context import TaskContext
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
class CodeGenerationAgent:
|
| 67 |
+
def __init__(self, llm: LLMProvider) -> None:
|
| 68 |
+
self.llm = llm
|
| 69 |
+
|
| 70 |
+
async def generate(self, context: TaskContext) -> dict[str, Any]:
|
| 71 |
+
prompt = (
|
| 72 |
+
f"User request:\n{context.user_prompt}\n\n"
|
| 73 |
+
"Generate the implementation files for this project."
|
| 74 |
+
)
|
| 75 |
+
try:
|
| 76 |
+
structured = await self.llm.generate_structured(
|
| 77 |
+
prompt,
|
| 78 |
+
schema=CODE_GENERATION_SCHEMA,
|
| 79 |
+
system_prompt=CODE_GENERATION_SYSTEM_PROMPT,
|
| 80 |
+
temperature=0.2,
|
| 81 |
+
)
|
| 82 |
+
result = CodeGenerationResult.from_llm_payload(structured)
|
| 83 |
+
return result.model_dump()
|
| 84 |
+
except ValueError as exc:
|
| 85 |
+
raise TaskExecutionError(str(exc), error_type="CODE_GENERATION_ERROR") from exc
|
| 86 |
+
except Exception as exc:
|
| 87 |
+
raise TaskExecutionError(
|
| 88 |
+
f"Code generation failed: {exc}",
|
| 89 |
+
error_type="CODE_GENERATION_ERROR",
|
| 90 |
+
) from exc
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
async def run_code_generation(context: TaskContext) -> dict[str, Any]:
|
| 94 |
+
agent = CodeGenerationAgent(context.llm)
|
| 95 |
+
return await agent.generate(context)
|
AutoForge/backend/agents/diagnosis_agent.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Any
|
| 2 |
+
|
| 3 |
+
from pydantic import BaseModel
|
| 4 |
+
|
| 5 |
+
from backend.llm.base import LLMProvider
|
| 6 |
+
from backend.orchestration.failure_classifier import ClassifiedFailure
|
| 7 |
+
from backend.orchestration.task_context import TaskContext
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class DiagnosisResult(BaseModel):
|
| 11 |
+
error_type: str
|
| 12 |
+
root_cause: str
|
| 13 |
+
recommended_fix: str
|
| 14 |
+
affected_files: list[str] = []
|
| 15 |
+
|
| 16 |
+
def to_dict(self) -> dict[str, Any]:
|
| 17 |
+
return self.model_dump()
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
DIAGNOSIS_SCHEMA: dict[str, Any] = {
|
| 21 |
+
"type": "object",
|
| 22 |
+
"properties": {
|
| 23 |
+
"error_type": {"type": "string"},
|
| 24 |
+
"root_cause": {"type": "string"},
|
| 25 |
+
"recommended_fix": {"type": "string"},
|
| 26 |
+
"affected_files": {"type": "array", "items": {"type": "string"}},
|
| 27 |
+
},
|
| 28 |
+
"required": ["error_type", "root_cause", "recommended_fix", "affected_files"],
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
DIAGNOSIS_SYSTEM_PROMPT = """You are a failure diagnosis agent.
|
| 32 |
+
Analyze the workflow failure context and return structured JSON with root cause and fix recommendation.
|
| 33 |
+
Return only valid JSON."""
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
class DiagnosisAgent:
|
| 37 |
+
def __init__(self, llm: LLMProvider) -> None:
|
| 38 |
+
self.llm = llm
|
| 39 |
+
|
| 40 |
+
async def diagnose(
|
| 41 |
+
self,
|
| 42 |
+
*,
|
| 43 |
+
user_prompt: str,
|
| 44 |
+
failure: ClassifiedFailure,
|
| 45 |
+
code_files: dict[str, dict[str, str]],
|
| 46 |
+
test_files: dict[str, dict[str, str]],
|
| 47 |
+
stdout: str = "",
|
| 48 |
+
stderr: str = "",
|
| 49 |
+
previous_attempts: list[dict[str, Any]] | None = None,
|
| 50 |
+
) -> DiagnosisResult:
|
| 51 |
+
code_listing = "\n".join(
|
| 52 |
+
f"--- {path} ---\n{meta['content']}" for path, meta in code_files.items()
|
| 53 |
+
)
|
| 54 |
+
test_listing = "\n".join(
|
| 55 |
+
f"--- {path} ---\n{meta['content']}" for path, meta in test_files.items()
|
| 56 |
+
)
|
| 57 |
+
prompt = (
|
| 58 |
+
f"User request:\n{user_prompt}\n\n"
|
| 59 |
+
f"Failure category: {failure.category.value}\n"
|
| 60 |
+
f"Failure message: {failure.message}\n"
|
| 61 |
+
f"stdout:\n{stdout}\n\nstderr:\n{stderr}\n\n"
|
| 62 |
+
f"Code files:\n{code_listing}\n\nTest files:\n{test_listing}\n\n"
|
| 63 |
+
f"Previous attempts: {len(previous_attempts or [])}\n"
|
| 64 |
+
"Diagnose the failure."
|
| 65 |
+
)
|
| 66 |
+
structured = await self.llm.generate_structured(
|
| 67 |
+
prompt,
|
| 68 |
+
schema=DIAGNOSIS_SCHEMA,
|
| 69 |
+
system_prompt=DIAGNOSIS_SYSTEM_PROMPT,
|
| 70 |
+
temperature=0.0,
|
| 71 |
+
)
|
| 72 |
+
return DiagnosisResult.model_validate(structured)
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
async def stub_diagnose(failure: ClassifiedFailure) -> DiagnosisResult:
|
| 76 |
+
return DiagnosisResult(
|
| 77 |
+
error_type=failure.category.value,
|
| 78 |
+
root_cause=f"Detected {failure.category.value}: {failure.message}",
|
| 79 |
+
recommended_fix="Apply automated repair to resolve the reported failure.",
|
| 80 |
+
affected_files=[],
|
| 81 |
+
)
|
AutoForge/backend/agents/planner.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
from typing import Any
|
| 3 |
+
|
| 4 |
+
from backend.llm.base import LLMProvider
|
| 5 |
+
from backend.orchestration.dag_schema import DAGPlan, DAGTaskDefinition, TaskType
|
| 6 |
+
from backend.orchestration.errors import PlannerError
|
| 7 |
+
|
| 8 |
+
PLANNER_SYSTEM_PROMPT = """You are a software workflow planner.
|
| 9 |
+
Given a user coding request, produce a dependency-aware execution DAG as JSON.
|
| 10 |
+
|
| 11 |
+
Rules:
|
| 12 |
+
- Return ONLY valid JSON matching the schema.
|
| 13 |
+
- Use unique task ids (short labels like A, B, C or descriptive ids).
|
| 14 |
+
- Task types must be one of: code_generation, test_generation, static_analysis, execute.
|
| 15 |
+
- Typical flow: code_generation first, then test_generation and static_analysis in parallel, then execute depending on both.
|
| 16 |
+
- depends_on must list task ids that must complete before this task runs.
|
| 17 |
+
- Do not create cycles.
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
PLANNER_OUTPUT_SCHEMA: dict[str, Any] = {
|
| 21 |
+
"type": "object",
|
| 22 |
+
"properties": {
|
| 23 |
+
"tasks": {
|
| 24 |
+
"type": "array",
|
| 25 |
+
"items": {
|
| 26 |
+
"type": "object",
|
| 27 |
+
"properties": {
|
| 28 |
+
"id": {"type": "string"},
|
| 29 |
+
"type": {
|
| 30 |
+
"type": "string",
|
| 31 |
+
"enum": [t.value for t in TaskType],
|
| 32 |
+
},
|
| 33 |
+
"depends_on": {
|
| 34 |
+
"type": "array",
|
| 35 |
+
"items": {"type": "string"},
|
| 36 |
+
},
|
| 37 |
+
},
|
| 38 |
+
"required": ["id", "type", "depends_on"],
|
| 39 |
+
},
|
| 40 |
+
}
|
| 41 |
+
},
|
| 42 |
+
"required": ["tasks"],
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
class PlannerAgent:
|
| 47 |
+
"""Generates a structured DAG plan from a user prompt."""
|
| 48 |
+
|
| 49 |
+
def __init__(self, llm: LLMProvider) -> None:
|
| 50 |
+
self.llm = llm
|
| 51 |
+
|
| 52 |
+
async def create_plan(self, user_prompt: str, *, context: str | None = None) -> DAGPlan:
|
| 53 |
+
prompt = user_prompt
|
| 54 |
+
if context:
|
| 55 |
+
prompt = f"Relevant context:\n{context}\n\nUser request:\n{user_prompt}"
|
| 56 |
+
|
| 57 |
+
try:
|
| 58 |
+
structured = await self.llm.generate_structured(
|
| 59 |
+
prompt,
|
| 60 |
+
schema=PLANNER_OUTPUT_SCHEMA,
|
| 61 |
+
system_prompt=PLANNER_SYSTEM_PROMPT,
|
| 62 |
+
temperature=0.0,
|
| 63 |
+
)
|
| 64 |
+
return self.parse_plan(structured)
|
| 65 |
+
except PlannerError:
|
| 66 |
+
raise
|
| 67 |
+
except Exception as exc:
|
| 68 |
+
raise PlannerError(f"Planner LLM call failed: {exc}") from exc
|
| 69 |
+
|
| 70 |
+
@staticmethod
|
| 71 |
+
def parse_plan(data: dict[str, Any]) -> DAGPlan:
|
| 72 |
+
try:
|
| 73 |
+
tasks = [
|
| 74 |
+
DAGTaskDefinition(
|
| 75 |
+
id=task["id"],
|
| 76 |
+
type=TaskType(task["type"]),
|
| 77 |
+
depends_on=task.get("depends_on", []),
|
| 78 |
+
)
|
| 79 |
+
for task in data["tasks"]
|
| 80 |
+
]
|
| 81 |
+
return DAGPlan(tasks=tasks)
|
| 82 |
+
except (KeyError, TypeError, ValueError) as exc:
|
| 83 |
+
raise PlannerError(f"Invalid planner output structure: {exc}") from exc
|
| 84 |
+
|
| 85 |
+
@staticmethod
|
| 86 |
+
def parse_plan_json(raw_json: str) -> DAGPlan:
|
| 87 |
+
try:
|
| 88 |
+
data = json.loads(raw_json)
|
| 89 |
+
except json.JSONDecodeError as exc:
|
| 90 |
+
raise PlannerError(f"Planner output is not valid JSON: {exc}") from exc
|
| 91 |
+
return PlannerAgent.parse_plan(data)
|
AutoForge/backend/agents/repair_agent.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Any
|
| 2 |
+
|
| 3 |
+
from pydantic import BaseModel
|
| 4 |
+
|
| 5 |
+
from backend.agents.code_agent import CodeGenerationResult
|
| 6 |
+
from backend.llm.base import LLMProvider
|
| 7 |
+
from backend.agents.diagnosis_agent import DiagnosisResult
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class RepairResult(BaseModel):
|
| 11 |
+
files: dict[str, dict[str, str]]
|
| 12 |
+
repair_summary: str
|
| 13 |
+
status: str = "repaired"
|
| 14 |
+
|
| 15 |
+
def to_dict(self) -> dict[str, Any]:
|
| 16 |
+
return {
|
| 17 |
+
"files": self.files,
|
| 18 |
+
"repair_summary": self.repair_summary,
|
| 19 |
+
"status": self.status,
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
REPAIR_SCHEMA: dict[str, Any] = {
|
| 24 |
+
"type": "object",
|
| 25 |
+
"properties": {
|
| 26 |
+
"files": {
|
| 27 |
+
"type": "array",
|
| 28 |
+
"items": {
|
| 29 |
+
"type": "object",
|
| 30 |
+
"properties": {
|
| 31 |
+
"path": {"type": "string"},
|
| 32 |
+
"content": {"type": "string"},
|
| 33 |
+
"language": {"type": "string"},
|
| 34 |
+
},
|
| 35 |
+
"required": ["path", "content"],
|
| 36 |
+
},
|
| 37 |
+
},
|
| 38 |
+
"repair_summary": {"type": "string"},
|
| 39 |
+
},
|
| 40 |
+
"required": ["files", "repair_summary"],
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
REPAIR_SYSTEM_PROMPT = """You are a code repair agent.
|
| 44 |
+
Given the diagnosis and current project files, produce repaired files as JSON.
|
| 45 |
+
Return only valid JSON with files array and repair_summary."""
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
class RepairAgent:
|
| 49 |
+
def __init__(self, llm: LLMProvider) -> None:
|
| 50 |
+
self.llm = llm
|
| 51 |
+
|
| 52 |
+
async def repair(
|
| 53 |
+
self,
|
| 54 |
+
*,
|
| 55 |
+
user_prompt: str,
|
| 56 |
+
diagnosis: DiagnosisResult,
|
| 57 |
+
code_files: dict[str, dict[str, str]],
|
| 58 |
+
test_files: dict[str, dict[str, str]],
|
| 59 |
+
) -> RepairResult:
|
| 60 |
+
all_files = {**code_files, **test_files}
|
| 61 |
+
listing = "\n".join(
|
| 62 |
+
f"--- {path} ---\n{meta['content']}" for path, meta in all_files.items()
|
| 63 |
+
)
|
| 64 |
+
prompt = (
|
| 65 |
+
f"User request:\n{user_prompt}\n\n"
|
| 66 |
+
f"Diagnosis:\n{diagnosis.model_dump_json()}\n\n"
|
| 67 |
+
f"Current files:\n{listing}\n\n"
|
| 68 |
+
"Produce repaired project files."
|
| 69 |
+
)
|
| 70 |
+
structured = await self.llm.generate_structured(
|
| 71 |
+
prompt,
|
| 72 |
+
schema=REPAIR_SCHEMA,
|
| 73 |
+
system_prompt=REPAIR_SYSTEM_PROMPT,
|
| 74 |
+
temperature=0.1,
|
| 75 |
+
)
|
| 76 |
+
generated = CodeGenerationResult.from_llm_payload(structured)
|
| 77 |
+
return RepairResult(
|
| 78 |
+
files=generated.files,
|
| 79 |
+
repair_summary=structured.get("repair_summary", "Repaired project files"),
|
| 80 |
+
)
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def stub_repair(
|
| 84 |
+
code_files: dict[str, dict[str, str]],
|
| 85 |
+
test_files: dict[str, dict[str, str]],
|
| 86 |
+
*,
|
| 87 |
+
fix_code: bool = True,
|
| 88 |
+
) -> RepairResult:
|
| 89 |
+
"""Stub repair that fixes a common broken add() implementation for tests."""
|
| 90 |
+
files = {**code_files, **test_files}
|
| 91 |
+
if fix_code and "main.py" in files:
|
| 92 |
+
content = files["main.py"]["content"]
|
| 93 |
+
if "return a - b" in content or "return a+b-1" in content:
|
| 94 |
+
files["main.py"] = {
|
| 95 |
+
**files["main.py"],
|
| 96 |
+
"content": "def add(a, b):\n return a + b\n",
|
| 97 |
+
}
|
| 98 |
+
return RepairResult(
|
| 99 |
+
files=files,
|
| 100 |
+
repair_summary="Stub repair applied",
|
| 101 |
+
)
|
AutoForge/backend/agents/task_handlers.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
from collections.abc import Awaitable, Callable
|
| 3 |
+
from typing import Any
|
| 4 |
+
|
| 5 |
+
from backend.agents.code_agent import run_code_generation
|
| 6 |
+
from backend.agents.test_agent import run_test_generation
|
| 7 |
+
from backend.analysis.static_analyzer import StaticAnalyzer
|
| 8 |
+
from backend.execution.verification import run_verification
|
| 9 |
+
from backend.orchestration.dag_schema import TaskType
|
| 10 |
+
from backend.orchestration.errors import TaskExecutionError
|
| 11 |
+
from backend.orchestration.task_context import TaskContext
|
| 12 |
+
|
| 13 |
+
TaskHandler = Callable[[TaskContext], Awaitable[dict[str, Any]]]
|
| 14 |
+
|
| 15 |
+
STUB_CODE_FILES = {
|
| 16 |
+
"main.py": {
|
| 17 |
+
"content": "def add(a, b):\n return a - b\n",
|
| 18 |
+
"language": "python",
|
| 19 |
+
},
|
| 20 |
+
"requirements.txt": {"content": "pytest\n", "language": "text"},
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
STUB_TEST_FILES = {
|
| 24 |
+
"tests/test_main.py": {
|
| 25 |
+
"content": "from main import add\n\ndef test_add():\n assert add(1, 2) == 3\n",
|
| 26 |
+
"language": "python",
|
| 27 |
+
}
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
async def _handle_code_generation(context: TaskContext) -> dict[str, Any]:
|
| 32 |
+
if context.use_stubs:
|
| 33 |
+
return await _stub_handler(context, TaskType.CODE_GENERATION)
|
| 34 |
+
return await run_code_generation(context)
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
async def _handle_test_generation(context: TaskContext) -> dict[str, Any]:
|
| 38 |
+
if context.use_stubs:
|
| 39 |
+
return await _stub_handler(context, TaskType.TEST_GENERATION)
|
| 40 |
+
return await run_test_generation(context)
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
async def _handle_static_analysis(context: TaskContext) -> dict[str, Any]:
|
| 44 |
+
if context.use_stubs:
|
| 45 |
+
return await _stub_handler(context, TaskType.STATIC_ANALYSIS)
|
| 46 |
+
files = context.generated_files
|
| 47 |
+
if not files:
|
| 48 |
+
raise TaskExecutionError(
|
| 49 |
+
"No generated code available for static analysis",
|
| 50 |
+
error_type="STATIC_ANALYSIS_ERROR",
|
| 51 |
+
)
|
| 52 |
+
analyzer = StaticAnalyzer()
|
| 53 |
+
result = analyzer.analyze_files(files)
|
| 54 |
+
if result.status == "FAIL":
|
| 55 |
+
raise TaskExecutionError(
|
| 56 |
+
"Static analysis failed",
|
| 57 |
+
error_type="STATIC_ANALYSIS_ERROR",
|
| 58 |
+
)
|
| 59 |
+
return result.to_dict()
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
async def _handle_execute(context: TaskContext) -> dict[str, Any]:
|
| 63 |
+
if context.use_stubs and not context.stub_execute_calls_docker:
|
| 64 |
+
return await _stub_handler(context, TaskType.EXECUTE)
|
| 65 |
+
return await run_verification(context)
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
async def _stub_handler(context: TaskContext, task_type: TaskType) -> dict[str, Any]:
|
| 69 |
+
await asyncio.sleep(context.stub_delay)
|
| 70 |
+
if context.fail_task == task_type.value:
|
| 71 |
+
raise TaskExecutionError(
|
| 72 |
+
f"{task_type.value} failed",
|
| 73 |
+
error_type=f"{task_type.value.upper()}_ERROR",
|
| 74 |
+
)
|
| 75 |
+
if task_type == TaskType.CODE_GENERATION:
|
| 76 |
+
return {"status": "generated", "files": dict(STUB_CODE_FILES)}
|
| 77 |
+
if task_type == TaskType.TEST_GENERATION:
|
| 78 |
+
return {"status": "generated", "files": dict(STUB_TEST_FILES)}
|
| 79 |
+
if task_type == TaskType.STATIC_ANALYSIS:
|
| 80 |
+
return {"status": "PASS", "errors": [], "warnings": []}
|
| 81 |
+
if task_type == TaskType.EXECUTE:
|
| 82 |
+
return {"status": "PASS", "stdout": "", "stderr": "", "exit_code": 0}
|
| 83 |
+
return {"status": "planned", "files": {}, "task_type": task_type.value}
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
TASK_HANDLERS: dict[TaskType, TaskHandler] = {
|
| 87 |
+
TaskType.CODE_GENERATION: _handle_code_generation,
|
| 88 |
+
TaskType.TEST_GENERATION: _handle_test_generation,
|
| 89 |
+
TaskType.STATIC_ANALYSIS: _handle_static_analysis,
|
| 90 |
+
TaskType.EXECUTE: _handle_execute,
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def get_task_handler(task_type: TaskType) -> TaskHandler:
|
| 95 |
+
handler = TASK_HANDLERS.get(task_type)
|
| 96 |
+
if handler is None:
|
| 97 |
+
raise TaskExecutionError(
|
| 98 |
+
f"No handler registered for task type '{task_type}'",
|
| 99 |
+
error_type="TASK_EXECUTION_ERROR",
|
| 100 |
+
)
|
| 101 |
+
return handler
|
AutoForge/backend/agents/test_agent.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Any
|
| 2 |
+
|
| 3 |
+
from pydantic import BaseModel
|
| 4 |
+
|
| 5 |
+
from backend.llm.base import LLMProvider
|
| 6 |
+
from backend.orchestration.errors import TaskExecutionError
|
| 7 |
+
from backend.orchestration.task_context import TaskContext
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class TestGenerationResult(BaseModel):
|
| 11 |
+
files: dict[str, dict[str, str]]
|
| 12 |
+
status: str = "generated"
|
| 13 |
+
|
| 14 |
+
@classmethod
|
| 15 |
+
def from_llm_payload(cls, payload: dict[str, Any]) -> "TestGenerationResult":
|
| 16 |
+
files: dict[str, dict[str, str]] = {}
|
| 17 |
+
for item in payload.get("files", []):
|
| 18 |
+
path = item["path"].lstrip("/").replace("\\", "/")
|
| 19 |
+
files[path] = {
|
| 20 |
+
"content": item["content"],
|
| 21 |
+
"language": item.get("language", "python"),
|
| 22 |
+
}
|
| 23 |
+
if not files:
|
| 24 |
+
raise ValueError("Test generation produced no files")
|
| 25 |
+
return cls(files=files)
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
TEST_GENERATION_SCHEMA: dict[str, Any] = {
|
| 29 |
+
"type": "object",
|
| 30 |
+
"properties": {
|
| 31 |
+
"files": {
|
| 32 |
+
"type": "array",
|
| 33 |
+
"items": {
|
| 34 |
+
"type": "object",
|
| 35 |
+
"properties": {
|
| 36 |
+
"path": {"type": "string"},
|
| 37 |
+
"content": {"type": "string"},
|
| 38 |
+
"language": {"type": "string"},
|
| 39 |
+
},
|
| 40 |
+
"required": ["path", "content"],
|
| 41 |
+
},
|
| 42 |
+
}
|
| 43 |
+
},
|
| 44 |
+
"required": ["files"],
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
TEST_GENERATION_SYSTEM_PROMPT = """You are a test generation agent.
|
| 48 |
+
Given generated source code, produce pytest test files.
|
| 49 |
+
Return JSON with a files array (paths under tests/).
|
| 50 |
+
Do not modify implementation files.
|
| 51 |
+
Return only valid JSON."""
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
class TestGenerationAgent:
|
| 55 |
+
def __init__(self, llm: LLMProvider) -> None:
|
| 56 |
+
self.llm = llm
|
| 57 |
+
|
| 58 |
+
async def generate(self, context: TaskContext) -> dict[str, Any]:
|
| 59 |
+
code_files = context.generated_files
|
| 60 |
+
if not code_files:
|
| 61 |
+
raise TaskExecutionError(
|
| 62 |
+
"No generated code available for test generation",
|
| 63 |
+
error_type="TEST_GENERATION_ERROR",
|
| 64 |
+
)
|
| 65 |
+
|
| 66 |
+
file_listing = "\n\n".join(
|
| 67 |
+
f"--- {path} ---\n{meta['content']}" for path, meta in code_files.items()
|
| 68 |
+
)
|
| 69 |
+
prompt = (
|
| 70 |
+
f"User request:\n{context.user_prompt}\n\n"
|
| 71 |
+
f"Generated source files:\n{file_listing}\n\n"
|
| 72 |
+
"Generate pytest tests for the implementation."
|
| 73 |
+
)
|
| 74 |
+
try:
|
| 75 |
+
structured = await self.llm.generate_structured(
|
| 76 |
+
prompt,
|
| 77 |
+
schema=TEST_GENERATION_SCHEMA,
|
| 78 |
+
system_prompt=TEST_GENERATION_SYSTEM_PROMPT,
|
| 79 |
+
temperature=0.2,
|
| 80 |
+
)
|
| 81 |
+
result = TestGenerationResult.from_llm_payload(structured)
|
| 82 |
+
return result.model_dump()
|
| 83 |
+
except ValueError as exc:
|
| 84 |
+
raise TaskExecutionError(str(exc), error_type="TEST_GENERATION_ERROR") from exc
|
| 85 |
+
except Exception as exc:
|
| 86 |
+
raise TaskExecutionError(
|
| 87 |
+
f"Test generation failed: {exc}",
|
| 88 |
+
error_type="TEST_GENERATION_ERROR",
|
| 89 |
+
) from exc
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
async def run_test_generation(context: TaskContext) -> dict[str, Any]:
|
| 93 |
+
agent = TestGenerationAgent(context.llm)
|
| 94 |
+
return await agent.generate(context)
|
AutoForge/backend/analysis/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Static analysis — implemented in Phase 2+."""
|
AutoForge/backend/analysis/__pycache__/__init__.cpython-312.pyc
ADDED
|
Binary file (227 Bytes). View file
|
|
|
AutoForge/backend/analysis/__pycache__/static_analyzer.cpython-312.pyc
ADDED
|
Binary file (5.47 kB). View file
|
|
|
AutoForge/backend/analysis/static_analyzer.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import ast
|
| 2 |
+
import re
|
| 3 |
+
from dataclasses import dataclass, field
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
from typing import Any
|
| 6 |
+
|
| 7 |
+
from backend.orchestration.errors import TaskExecutionError
|
| 8 |
+
from backend.orchestration.task_context import TaskContext
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
@dataclass
|
| 12 |
+
class AnalysisIssue:
|
| 13 |
+
file: str
|
| 14 |
+
line: int
|
| 15 |
+
message: str
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
@dataclass
|
| 19 |
+
class StaticAnalysisResult:
|
| 20 |
+
status: str
|
| 21 |
+
errors: list[dict[str, Any]] = field(default_factory=list)
|
| 22 |
+
warnings: list[dict[str, Any]] = field(default_factory=list)
|
| 23 |
+
|
| 24 |
+
def to_dict(self) -> dict[str, Any]:
|
| 25 |
+
return {
|
| 26 |
+
"status": self.status,
|
| 27 |
+
"errors": self.errors,
|
| 28 |
+
"warnings": self.warnings,
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
class StaticAnalyzer:
|
| 33 |
+
"""Analyze generated Python files without executing them."""
|
| 34 |
+
|
| 35 |
+
def analyze_files(self, files: dict[str, dict[str, str]]) -> StaticAnalysisResult:
|
| 36 |
+
errors: list[dict[str, Any]] = []
|
| 37 |
+
warnings: list[dict[str, Any]] = []
|
| 38 |
+
|
| 39 |
+
python_files = {
|
| 40 |
+
path: meta["content"]
|
| 41 |
+
for path, meta in files.items()
|
| 42 |
+
if path.endswith(".py")
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
if not python_files:
|
| 46 |
+
errors.append(
|
| 47 |
+
{"file": "", "line": 0, "message": "No Python source files found to analyze"}
|
| 48 |
+
)
|
| 49 |
+
return StaticAnalysisResult(status="FAIL", errors=errors, warnings=warnings)
|
| 50 |
+
|
| 51 |
+
for path, content in python_files.items():
|
| 52 |
+
syntax_issues = self._check_syntax(path, content)
|
| 53 |
+
errors.extend(syntax_issues)
|
| 54 |
+
warnings.extend(self._check_imports(path, content))
|
| 55 |
+
|
| 56 |
+
status = "FAIL" if errors else "PASS"
|
| 57 |
+
return StaticAnalysisResult(status=status, errors=errors, warnings=warnings)
|
| 58 |
+
|
| 59 |
+
def _check_syntax(self, path: str, content: str) -> list[dict[str, Any]]:
|
| 60 |
+
try:
|
| 61 |
+
ast.parse(content, filename=path)
|
| 62 |
+
return []
|
| 63 |
+
except SyntaxError as exc:
|
| 64 |
+
return [
|
| 65 |
+
{
|
| 66 |
+
"file": path,
|
| 67 |
+
"line": exc.lineno or 0,
|
| 68 |
+
"message": f"Syntax error: {exc.msg}",
|
| 69 |
+
}
|
| 70 |
+
]
|
| 71 |
+
|
| 72 |
+
def _check_imports(self, path: str, content: str) -> list[dict[str, Any]]:
|
| 73 |
+
warnings: list[dict[str, Any]] = []
|
| 74 |
+
for line_no, line in enumerate(content.splitlines(), start=1):
|
| 75 |
+
stripped = line.strip()
|
| 76 |
+
if stripped.startswith("import ") or stripped.startswith("from "):
|
| 77 |
+
if re.search(r"\b(os\.system|subprocess\.|eval\(|exec\()", stripped):
|
| 78 |
+
warnings.append(
|
| 79 |
+
{
|
| 80 |
+
"file": path,
|
| 81 |
+
"line": line_no,
|
| 82 |
+
"message": "Suspicious import or call pattern detected",
|
| 83 |
+
}
|
| 84 |
+
)
|
| 85 |
+
return warnings
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
async def run_static_analysis(context: TaskContext) -> dict[str, Any]:
|
| 89 |
+
files = context.all_project_files
|
| 90 |
+
if not files:
|
| 91 |
+
raise TaskExecutionError(
|
| 92 |
+
"No generated files available for static analysis",
|
| 93 |
+
error_type="STATIC_ANALYSIS_ERROR",
|
| 94 |
+
)
|
| 95 |
+
analyzer = StaticAnalyzer()
|
| 96 |
+
result = analyzer.analyze_files(files)
|
| 97 |
+
if result.status == "FAIL":
|
| 98 |
+
raise TaskExecutionError(
|
| 99 |
+
"Static analysis failed",
|
| 100 |
+
error_type="STATIC_ANALYSIS_ERROR",
|
| 101 |
+
)
|
| 102 |
+
return result.to_dict()
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def analyze_workspace(workspace: Path) -> StaticAnalysisResult:
|
| 106 |
+
files: dict[str, dict[str, str]] = {}
|
| 107 |
+
for path in workspace.rglob("*.py"):
|
| 108 |
+
relative = path.relative_to(workspace).as_posix()
|
| 109 |
+
files[relative] = {"content": path.read_text(encoding="utf-8"), "language": "python"}
|
| 110 |
+
return StaticAnalyzer().analyze_files(files)
|
AutoForge/backend/api/__init__.py
ADDED
|
File without changes
|
AutoForge/backend/api/__pycache__/__init__.cpython-312.pyc
ADDED
|
Binary file (160 Bytes). View file
|
|
|
AutoForge/backend/api/__pycache__/auth.cpython-312.pyc
ADDED
|
Binary file (3.09 kB). View file
|
|
|
AutoForge/backend/api/__pycache__/providers.cpython-312.pyc
ADDED
|
Binary file (1.41 kB). View file
|
|
|
AutoForge/backend/api/__pycache__/schemas.cpython-312.pyc
ADDED
|
Binary file (6.76 kB). View file
|
|
|
AutoForge/backend/api/__pycache__/settings.cpython-312.pyc
ADDED
|
Binary file (4.24 kB). View file
|
|
|
AutoForge/backend/api/__pycache__/workflows.cpython-312.pyc
ADDED
|
Binary file (18.8 kB). View file
|
|
|
AutoForge/backend/api/auth.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, HTTPException, status
|
| 2 |
+
|
| 3 |
+
from backend.api.schemas import LoginRequest, SignupRequest, TokenResponse, UserResponse
|
| 4 |
+
from backend.core.dependencies import get_current_user, get_repository
|
| 5 |
+
from backend.core.security import create_access_token, hash_password, verify_password
|
| 6 |
+
from backend.db.models import User
|
| 7 |
+
from backend.memory.workflow_repository import WorkflowRepository
|
| 8 |
+
|
| 9 |
+
router = APIRouter(prefix="/auth", tags=["auth"])
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
@router.post("/signup", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
|
| 13 |
+
async def signup(
|
| 14 |
+
payload: SignupRequest,
|
| 15 |
+
repo: WorkflowRepository = Depends(get_repository),
|
| 16 |
+
) -> User:
|
| 17 |
+
if await repo.get_user_by_email(payload.email):
|
| 18 |
+
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Email already registered")
|
| 19 |
+
if await repo.get_user_by_username(payload.username):
|
| 20 |
+
raise HTTPException(
|
| 21 |
+
status_code=status.HTTP_409_CONFLICT, detail="Username already taken"
|
| 22 |
+
)
|
| 23 |
+
|
| 24 |
+
user = await repo.create_user(
|
| 25 |
+
email=payload.email,
|
| 26 |
+
username=payload.username,
|
| 27 |
+
password_hash=hash_password(payload.password),
|
| 28 |
+
)
|
| 29 |
+
return user
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
@router.post("/login", response_model=TokenResponse)
|
| 33 |
+
async def login(
|
| 34 |
+
payload: LoginRequest,
|
| 35 |
+
repo: WorkflowRepository = Depends(get_repository),
|
| 36 |
+
) -> TokenResponse:
|
| 37 |
+
user = await repo.get_user_by_email(payload.email)
|
| 38 |
+
if user is None or not verify_password(payload.password, user.password_hash):
|
| 39 |
+
raise HTTPException(
|
| 40 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 41 |
+
detail="Invalid email or password",
|
| 42 |
+
)
|
| 43 |
+
|
| 44 |
+
token = create_access_token(str(user.id))
|
| 45 |
+
return TokenResponse(access_token=token)
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
@router.get("/me", response_model=UserResponse)
|
| 49 |
+
async def me(current_user: User = Depends(get_current_user)) -> User:
|
| 50 |
+
return current_user
|
AutoForge/backend/api/providers.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter
|
| 2 |
+
|
| 3 |
+
from backend.api.schemas import ProviderInfo, ProviderListResponse
|
| 4 |
+
from backend.llm.providers import list_supported_providers
|
| 5 |
+
|
| 6 |
+
router = APIRouter(prefix="/settings", tags=["settings"])
|
| 7 |
+
|
| 8 |
+
OPENAI_MODELS = [
|
| 9 |
+
"gpt-4o",
|
| 10 |
+
"gpt-4o-mini",
|
| 11 |
+
"gpt-4.1",
|
| 12 |
+
"gpt-4.1-mini",
|
| 13 |
+
]
|
| 14 |
+
|
| 15 |
+
GEMINI_MODELS = [
|
| 16 |
+
"gemini-3.1-pro",
|
| 17 |
+
"gemini-3.1-flash",
|
| 18 |
+
]
|
| 19 |
+
|
| 20 |
+
PROVIDER_MODELS: dict[str, list[str]] = {
|
| 21 |
+
"openai": OPENAI_MODELS,
|
| 22 |
+
"gemini": GEMINI_MODELS,
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
@router.get("/providers", response_model=ProviderListResponse)
|
| 27 |
+
async def list_providers() -> ProviderListResponse:
|
| 28 |
+
providers = [
|
| 29 |
+
ProviderInfo(
|
| 30 |
+
id=provider_id,
|
| 31 |
+
name=provider_id.replace("_", " ").title(),
|
| 32 |
+
models=PROVIDER_MODELS.get(provider_id, []),
|
| 33 |
+
)
|
| 34 |
+
for provider_id in list_supported_providers()
|
| 35 |
+
]
|
| 36 |
+
return ProviderListResponse(providers=providers)
|
AutoForge/backend/api/schemas.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from datetime import datetime
|
| 2 |
+
|
| 3 |
+
from pydantic import BaseModel, ConfigDict, EmailStr, Field
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class SignupRequest(BaseModel):
|
| 7 |
+
email: EmailStr
|
| 8 |
+
username: str = Field(min_length=3, max_length=100)
|
| 9 |
+
password: str = Field(min_length=8, max_length=128)
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class LoginRequest(BaseModel):
|
| 13 |
+
email: EmailStr
|
| 14 |
+
password: str
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class TokenResponse(BaseModel):
|
| 18 |
+
access_token: str
|
| 19 |
+
token_type: str = "bearer"
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class UserResponse(BaseModel):
|
| 23 |
+
model_config = ConfigDict(from_attributes=True)
|
| 24 |
+
|
| 25 |
+
id: int
|
| 26 |
+
email: str
|
| 27 |
+
username: str
|
| 28 |
+
created_at: datetime
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
class WorkflowCreateRequest(BaseModel):
|
| 32 |
+
user_prompt: str = Field(min_length=1)
|
| 33 |
+
llm_provider: str = Field(min_length=1, max_length=64)
|
| 34 |
+
llm_model: str = Field(min_length=1, max_length=128)
|
| 35 |
+
api_key: str | None = Field(default=None, min_length=1)
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
class WorkflowResponse(BaseModel):
|
| 39 |
+
model_config = ConfigDict(from_attributes=True)
|
| 40 |
+
|
| 41 |
+
workflow_id: str
|
| 42 |
+
user_prompt: str
|
| 43 |
+
status: str
|
| 44 |
+
llm_provider: str | None
|
| 45 |
+
llm_model: str | None
|
| 46 |
+
planner_output: dict | None
|
| 47 |
+
created_at: datetime
|
| 48 |
+
completed_at: datetime | None
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
class WorkflowListResponse(BaseModel):
|
| 52 |
+
workflows: list[WorkflowResponse]
|
| 53 |
+
total: int
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
class ApiCredentialCreateRequest(BaseModel):
|
| 57 |
+
provider: str = Field(min_length=1, max_length=64)
|
| 58 |
+
api_key: str = Field(min_length=1)
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
class ApiCredentialResponse(BaseModel):
|
| 62 |
+
model_config = ConfigDict(from_attributes=True)
|
| 63 |
+
|
| 64 |
+
id: int
|
| 65 |
+
provider: str
|
| 66 |
+
api_key_hint: str
|
| 67 |
+
created_at: datetime
|
| 68 |
+
updated_at: datetime
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
class HealthResponse(BaseModel):
|
| 72 |
+
status: str
|
| 73 |
+
database: str
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
class TaskResponse(BaseModel):
|
| 77 |
+
model_config = ConfigDict(from_attributes=True)
|
| 78 |
+
|
| 79 |
+
task_key: str
|
| 80 |
+
task_type: str
|
| 81 |
+
status: str
|
| 82 |
+
depends_on: list[str] | None
|
| 83 |
+
output: dict | None
|
| 84 |
+
started_at: datetime | None
|
| 85 |
+
completed_at: datetime | None
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
class TaskListResponse(BaseModel):
|
| 89 |
+
tasks: list[TaskResponse]
|
| 90 |
+
total: int
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
class EventResponse(BaseModel):
|
| 94 |
+
model_config = ConfigDict(from_attributes=True, populate_by_name=True)
|
| 95 |
+
|
| 96 |
+
id: int
|
| 97 |
+
event_type: str
|
| 98 |
+
message: str
|
| 99 |
+
task_id: int | None
|
| 100 |
+
metadata: dict | None = Field(default=None, validation_alias="event_metadata")
|
| 101 |
+
timestamp: datetime
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
class AttemptResponse(BaseModel):
|
| 105 |
+
model_config = ConfigDict(from_attributes=True)
|
| 106 |
+
|
| 107 |
+
id: int
|
| 108 |
+
task_id: int | None
|
| 109 |
+
attempt_number: int
|
| 110 |
+
generated_code: dict | None
|
| 111 |
+
generated_tests: dict | None
|
| 112 |
+
static_analysis_result: dict | None
|
| 113 |
+
stdout: str | None
|
| 114 |
+
stderr: str | None
|
| 115 |
+
exit_code: int | None
|
| 116 |
+
result: str | None
|
| 117 |
+
error: dict | None
|
| 118 |
+
created_at: datetime
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
class AttemptListResponse(BaseModel):
|
| 122 |
+
attempts: list[AttemptResponse]
|
| 123 |
+
total: int
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
class WorkflowStartRequest(BaseModel):
|
| 127 |
+
api_key: str | None = Field(default=None, min_length=1)
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
class ProviderInfo(BaseModel):
|
| 131 |
+
id: str
|
| 132 |
+
name: str
|
| 133 |
+
models: list[str]
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
class ProviderListResponse(BaseModel):
|
| 137 |
+
providers: list[ProviderInfo]
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
class EventListResponse(BaseModel):
|
| 141 |
+
events: list[EventResponse]
|
| 142 |
+
total: int
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
class WorkflowStartResponse(WorkflowResponse):
|
| 146 |
+
pass
|
AutoForge/backend/api/settings.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, HTTPException, status
|
| 2 |
+
from sqlalchemy import select
|
| 3 |
+
|
| 4 |
+
from backend.api.schemas import ApiCredentialCreateRequest, ApiCredentialResponse
|
| 5 |
+
from backend.core.dependencies import get_current_user, get_repository
|
| 6 |
+
from backend.core.encryption import encrypt_value, redact_secret
|
| 7 |
+
from backend.db.models import ApiCredential, User
|
| 8 |
+
from backend.db.session import get_db
|
| 9 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 10 |
+
|
| 11 |
+
router = APIRouter(prefix="/settings/api-credentials", tags=["settings"])
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
@router.post("", response_model=ApiCredentialResponse, status_code=status.HTTP_201_CREATED)
|
| 15 |
+
async def create_api_credential(
|
| 16 |
+
payload: ApiCredentialCreateRequest,
|
| 17 |
+
current_user: User = Depends(get_current_user),
|
| 18 |
+
db: AsyncSession = Depends(get_db),
|
| 19 |
+
) -> ApiCredentialResponse:
|
| 20 |
+
credential = ApiCredential(
|
| 21 |
+
user_id=current_user.id,
|
| 22 |
+
provider=payload.provider.lower().strip(),
|
| 23 |
+
encrypted_api_key=encrypt_value(payload.api_key),
|
| 24 |
+
)
|
| 25 |
+
db.add(credential)
|
| 26 |
+
await db.flush()
|
| 27 |
+
await db.refresh(credential)
|
| 28 |
+
return ApiCredentialResponse(
|
| 29 |
+
id=credential.id,
|
| 30 |
+
provider=credential.provider,
|
| 31 |
+
api_key_hint=redact_secret(payload.api_key),
|
| 32 |
+
created_at=credential.created_at,
|
| 33 |
+
updated_at=credential.updated_at,
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
@router.get("", response_model=list[ApiCredentialResponse])
|
| 38 |
+
async def list_api_credentials(
|
| 39 |
+
current_user: User = Depends(get_current_user),
|
| 40 |
+
db: AsyncSession = Depends(get_db),
|
| 41 |
+
) -> list[ApiCredentialResponse]:
|
| 42 |
+
result = await db.execute(
|
| 43 |
+
select(ApiCredential).where(ApiCredential.user_id == current_user.id)
|
| 44 |
+
)
|
| 45 |
+
credentials = result.scalars().all()
|
| 46 |
+
return [
|
| 47 |
+
ApiCredentialResponse(
|
| 48 |
+
id=cred.id,
|
| 49 |
+
provider=cred.provider,
|
| 50 |
+
api_key_hint="********",
|
| 51 |
+
created_at=cred.created_at,
|
| 52 |
+
updated_at=cred.updated_at,
|
| 53 |
+
)
|
| 54 |
+
for cred in credentials
|
| 55 |
+
]
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
@router.delete("/{credential_id}", status_code=status.HTTP_204_NO_CONTENT)
|
| 59 |
+
async def delete_api_credential(
|
| 60 |
+
credential_id: int,
|
| 61 |
+
current_user: User = Depends(get_current_user),
|
| 62 |
+
db: AsyncSession = Depends(get_db),
|
| 63 |
+
) -> None:
|
| 64 |
+
result = await db.execute(
|
| 65 |
+
select(ApiCredential).where(
|
| 66 |
+
ApiCredential.id == credential_id,
|
| 67 |
+
ApiCredential.user_id == current_user.id,
|
| 68 |
+
)
|
| 69 |
+
)
|
| 70 |
+
credential = result.scalar_one_or_none()
|
| 71 |
+
if credential is None:
|
| 72 |
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Credential not found")
|
| 73 |
+
await db.delete(credential)
|
AutoForge/backend/api/workflows.py
ADDED
|
@@ -0,0 +1,370 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
import json
|
| 3 |
+
import logging
|
| 4 |
+
|
| 5 |
+
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, status
|
| 6 |
+
from fastapi.responses import StreamingResponse
|
| 7 |
+
from sqlalchemy import select
|
| 8 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 9 |
+
|
| 10 |
+
from backend.api.schemas import (
|
| 11 |
+
AttemptListResponse,
|
| 12 |
+
AttemptResponse,
|
| 13 |
+
EventListResponse,
|
| 14 |
+
EventResponse,
|
| 15 |
+
TaskListResponse,
|
| 16 |
+
TaskResponse,
|
| 17 |
+
WorkflowCreateRequest,
|
| 18 |
+
WorkflowListResponse,
|
| 19 |
+
WorkflowResponse,
|
| 20 |
+
WorkflowStartRequest,
|
| 21 |
+
WorkflowStartResponse,
|
| 22 |
+
)
|
| 23 |
+
from backend.core.dependencies import get_current_user, get_repository
|
| 24 |
+
from backend.core.encryption import decrypt_value
|
| 25 |
+
from backend.core.security import decode_access_token
|
| 26 |
+
from backend.db.models import ApiCredential, Attempt, Event, User, Workflow, WorkflowStatus
|
| 27 |
+
from backend.db.session import AsyncSessionLocal, get_db
|
| 28 |
+
from backend.events.publisher import EventPublisher
|
| 29 |
+
from backend.llm.providers import get_llm_provider
|
| 30 |
+
from backend.memory.workflow_repository import WorkflowRepository
|
| 31 |
+
from backend.orchestration.workflow_runner import WorkflowRunner
|
| 32 |
+
|
| 33 |
+
logger = logging.getLogger(__name__)
|
| 34 |
+
router = APIRouter(prefix="/workflows", tags=["workflows"])
|
| 35 |
+
|
| 36 |
+
TERMINAL_WORKFLOW_STATUSES = {
|
| 37 |
+
WorkflowStatus.SUCCESS,
|
| 38 |
+
WorkflowStatus.FAILED,
|
| 39 |
+
WorkflowStatus.CANCELLED,
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def _to_workflow_response(workflow) -> WorkflowResponse:
|
| 44 |
+
return WorkflowResponse(
|
| 45 |
+
workflow_id=workflow.workflow_id,
|
| 46 |
+
user_prompt=workflow.user_prompt,
|
| 47 |
+
status=workflow.status,
|
| 48 |
+
llm_provider=workflow.llm_provider,
|
| 49 |
+
llm_model=workflow.llm_model,
|
| 50 |
+
planner_output=workflow.planner_output,
|
| 51 |
+
created_at=workflow.created_at,
|
| 52 |
+
completed_at=workflow.completed_at,
|
| 53 |
+
)
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
async def _resolve_api_key(
|
| 57 |
+
db: AsyncSession,
|
| 58 |
+
user_id: int,
|
| 59 |
+
provider: str,
|
| 60 |
+
request_api_key: str | None,
|
| 61 |
+
) -> str:
|
| 62 |
+
if request_api_key:
|
| 63 |
+
return request_api_key
|
| 64 |
+
|
| 65 |
+
result = await db.execute(
|
| 66 |
+
select(ApiCredential).where(
|
| 67 |
+
ApiCredential.user_id == user_id,
|
| 68 |
+
ApiCredential.provider == provider.lower().strip(),
|
| 69 |
+
)
|
| 70 |
+
)
|
| 71 |
+
credential = result.scalar_one_or_none()
|
| 72 |
+
if credential is None:
|
| 73 |
+
raise HTTPException(
|
| 74 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 75 |
+
detail="API key required. Provide api_key or store credentials in settings.",
|
| 76 |
+
)
|
| 77 |
+
return decrypt_value(credential.encrypted_api_key)
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
async def _execute_workflow_background(
|
| 81 |
+
workflow_id: str,
|
| 82 |
+
user_id: int,
|
| 83 |
+
api_key: str | None,
|
| 84 |
+
) -> None:
|
| 85 |
+
async with AsyncSessionLocal() as db:
|
| 86 |
+
try:
|
| 87 |
+
repo = WorkflowRepository(db)
|
| 88 |
+
workflow = await repo.get_workflow_by_public_id(workflow_id, user_id)
|
| 89 |
+
if workflow is None:
|
| 90 |
+
return
|
| 91 |
+
llm = get_llm_provider(
|
| 92 |
+
workflow.llm_provider,
|
| 93 |
+
api_key=api_key,
|
| 94 |
+
model=workflow.llm_model,
|
| 95 |
+
)
|
| 96 |
+
import shutil
|
| 97 |
+
|
| 98 |
+
has_docker = shutil.which("docker") is not None
|
| 99 |
+
if not has_docker:
|
| 100 |
+
raise RuntimeError("DOCKER_UNAVAILABLE: Sandbox unavailable — Docker is required to execute generated code.")
|
| 101 |
+
|
| 102 |
+
runner = WorkflowRunner(db)
|
| 103 |
+
await runner.run(workflow, llm)
|
| 104 |
+
await db.commit()
|
| 105 |
+
except Exception as exc:
|
| 106 |
+
logger.exception("Background workflow execution failed for %s", workflow_id)
|
| 107 |
+
await db.rollback()
|
| 108 |
+
try:
|
| 109 |
+
repo = WorkflowRepository(db)
|
| 110 |
+
workflow = await repo.get_workflow_by_public_id(workflow_id, user_id)
|
| 111 |
+
if workflow and workflow.status == WorkflowStatus.RUNNING:
|
| 112 |
+
runner = WorkflowRunner(db)
|
| 113 |
+
await runner._fail_workflow(
|
| 114 |
+
workflow,
|
| 115 |
+
message=str(exc),
|
| 116 |
+
error_type="WORKFLOW_EXECUTION_ERROR",
|
| 117 |
+
)
|
| 118 |
+
await db.commit()
|
| 119 |
+
except Exception:
|
| 120 |
+
logger.exception("Failed to mark workflow %s as failed", workflow_id)
|
| 121 |
+
await db.rollback()
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
@router.post("", response_model=WorkflowResponse, status_code=status.HTTP_201_CREATED)
|
| 125 |
+
async def create_workflow(
|
| 126 |
+
payload: WorkflowCreateRequest,
|
| 127 |
+
current_user: User = Depends(get_current_user),
|
| 128 |
+
repo: WorkflowRepository = Depends(get_repository),
|
| 129 |
+
db: AsyncSession = Depends(get_db),
|
| 130 |
+
) -> WorkflowResponse:
|
| 131 |
+
workflow = await repo.create_workflow(
|
| 132 |
+
user_id=current_user.id,
|
| 133 |
+
user_prompt=payload.user_prompt,
|
| 134 |
+
llm_provider=payload.llm_provider,
|
| 135 |
+
llm_model=payload.llm_model,
|
| 136 |
+
)
|
| 137 |
+
if payload.api_key:
|
| 138 |
+
existing = await db.execute(
|
| 139 |
+
select(ApiCredential).where(
|
| 140 |
+
ApiCredential.user_id == current_user.id,
|
| 141 |
+
ApiCredential.provider == payload.llm_provider.lower().strip(),
|
| 142 |
+
)
|
| 143 |
+
)
|
| 144 |
+
if existing.scalar_one_or_none() is None:
|
| 145 |
+
from backend.core.encryption import encrypt_value
|
| 146 |
+
|
| 147 |
+
db.add(
|
| 148 |
+
ApiCredential(
|
| 149 |
+
user_id=current_user.id,
|
| 150 |
+
provider=payload.llm_provider.lower().strip(),
|
| 151 |
+
encrypted_api_key=encrypt_value(payload.api_key),
|
| 152 |
+
)
|
| 153 |
+
)
|
| 154 |
+
return _to_workflow_response(workflow)
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
@router.post("/{workflow_id}/start", response_model=WorkflowStartResponse, status_code=status.HTTP_202_ACCEPTED)
|
| 158 |
+
async def start_workflow(
|
| 159 |
+
workflow_id: str,
|
| 160 |
+
payload: WorkflowStartRequest,
|
| 161 |
+
background_tasks: BackgroundTasks,
|
| 162 |
+
current_user: User = Depends(get_current_user),
|
| 163 |
+
repo: WorkflowRepository = Depends(get_repository),
|
| 164 |
+
db: AsyncSession = Depends(get_db),
|
| 165 |
+
) -> WorkflowStartResponse:
|
| 166 |
+
workflow = await repo.get_workflow_by_public_id(workflow_id, current_user.id)
|
| 167 |
+
if workflow is None:
|
| 168 |
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Workflow not found")
|
| 169 |
+
if workflow.status not in {WorkflowStatus.CREATED, WorkflowStatus.FAILED}:
|
| 170 |
+
raise HTTPException(
|
| 171 |
+
status_code=status.HTTP_409_CONFLICT,
|
| 172 |
+
detail=f"Workflow cannot be started from status {workflow.status}",
|
| 173 |
+
)
|
| 174 |
+
if not workflow.llm_provider or not workflow.llm_model:
|
| 175 |
+
raise HTTPException(
|
| 176 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 177 |
+
detail="Workflow is missing LLM provider configuration",
|
| 178 |
+
)
|
| 179 |
+
|
| 180 |
+
resolved_key = await _resolve_api_key(
|
| 181 |
+
db,
|
| 182 |
+
current_user.id,
|
| 183 |
+
workflow.llm_provider,
|
| 184 |
+
payload.api_key,
|
| 185 |
+
)
|
| 186 |
+
|
| 187 |
+
workflow.status = WorkflowStatus.RUNNING
|
| 188 |
+
await db.flush()
|
| 189 |
+
|
| 190 |
+
background_tasks.add_task(
|
| 191 |
+
_execute_workflow_background,
|
| 192 |
+
workflow_id,
|
| 193 |
+
current_user.id,
|
| 194 |
+
resolved_key,
|
| 195 |
+
)
|
| 196 |
+
return _to_workflow_response(workflow)
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
@router.get("", response_model=WorkflowListResponse)
|
| 200 |
+
async def list_workflows(
|
| 201 |
+
current_user: User = Depends(get_current_user),
|
| 202 |
+
repo: WorkflowRepository = Depends(get_repository),
|
| 203 |
+
) -> WorkflowListResponse:
|
| 204 |
+
workflows = await repo.list_workflows_for_user(current_user.id)
|
| 205 |
+
items = [_to_workflow_response(wf) for wf in workflows]
|
| 206 |
+
return WorkflowListResponse(workflows=items, total=len(items))
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
@router.get("/{workflow_id}", response_model=WorkflowResponse)
|
| 210 |
+
async def get_workflow(
|
| 211 |
+
workflow_id: str,
|
| 212 |
+
current_user: User = Depends(get_current_user),
|
| 213 |
+
repo: WorkflowRepository = Depends(get_repository),
|
| 214 |
+
) -> WorkflowResponse:
|
| 215 |
+
workflow = await repo.get_workflow_by_public_id(workflow_id, current_user.id)
|
| 216 |
+
if workflow is None:
|
| 217 |
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Workflow not found")
|
| 218 |
+
return _to_workflow_response(workflow)
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
@router.get("/{workflow_id}/tasks", response_model=TaskListResponse)
|
| 222 |
+
async def list_workflow_tasks(
|
| 223 |
+
workflow_id: str,
|
| 224 |
+
current_user: User = Depends(get_current_user),
|
| 225 |
+
repo: WorkflowRepository = Depends(get_repository),
|
| 226 |
+
db: AsyncSession = Depends(get_db),
|
| 227 |
+
) -> TaskListResponse:
|
| 228 |
+
from backend.memory.task_repository import TaskRepository
|
| 229 |
+
|
| 230 |
+
workflow = await repo.get_workflow_by_public_id(workflow_id, current_user.id)
|
| 231 |
+
if workflow is None:
|
| 232 |
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Workflow not found")
|
| 233 |
+
|
| 234 |
+
tasks = await TaskRepository(db).get_tasks_for_workflow(workflow.id)
|
| 235 |
+
items = [
|
| 236 |
+
TaskResponse(
|
| 237 |
+
task_key=task.task_key,
|
| 238 |
+
task_type=task.task_type,
|
| 239 |
+
status=task.status,
|
| 240 |
+
depends_on=task.depends_on,
|
| 241 |
+
output=task.output,
|
| 242 |
+
started_at=task.started_at,
|
| 243 |
+
completed_at=task.completed_at,
|
| 244 |
+
)
|
| 245 |
+
for task in tasks
|
| 246 |
+
]
|
| 247 |
+
return TaskListResponse(tasks=items, total=len(items))
|
| 248 |
+
|
| 249 |
+
|
| 250 |
+
@router.get("/{workflow_id}/events", response_model=EventListResponse)
|
| 251 |
+
async def list_workflow_events(
|
| 252 |
+
workflow_id: str,
|
| 253 |
+
after_id: int = 0,
|
| 254 |
+
current_user: User = Depends(get_current_user),
|
| 255 |
+
repo: WorkflowRepository = Depends(get_repository),
|
| 256 |
+
db: AsyncSession = Depends(get_db),
|
| 257 |
+
) -> EventListResponse:
|
| 258 |
+
workflow = await repo.get_workflow_by_public_id(workflow_id, current_user.id)
|
| 259 |
+
if workflow is None:
|
| 260 |
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Workflow not found")
|
| 261 |
+
|
| 262 |
+
query = (
|
| 263 |
+
select(Event)
|
| 264 |
+
.where(Event.workflow_id == workflow.id, Event.id > after_id)
|
| 265 |
+
.order_by(Event.timestamp.asc())
|
| 266 |
+
)
|
| 267 |
+
events = (await db.execute(query)).scalars().all()
|
| 268 |
+
items = [
|
| 269 |
+
EventResponse(
|
| 270 |
+
id=event.id,
|
| 271 |
+
event_type=event.event_type,
|
| 272 |
+
message=event.message,
|
| 273 |
+
task_id=event.task_id,
|
| 274 |
+
event_metadata=event.event_metadata,
|
| 275 |
+
timestamp=event.timestamp,
|
| 276 |
+
)
|
| 277 |
+
for event in events
|
| 278 |
+
]
|
| 279 |
+
return EventListResponse(events=items, total=len(items))
|
| 280 |
+
|
| 281 |
+
|
| 282 |
+
@router.get("/{workflow_id}/events/stream")
|
| 283 |
+
async def stream_workflow_events(
|
| 284 |
+
workflow_id: str,
|
| 285 |
+
token: str = Query(...),
|
| 286 |
+
after_id: int = Query(0),
|
| 287 |
+
db: AsyncSession = Depends(get_db),
|
| 288 |
+
):
|
| 289 |
+
user_id_str = decode_access_token(token)
|
| 290 |
+
if user_id_str is None:
|
| 291 |
+
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
|
| 292 |
+
user_id = int(user_id_str)
|
| 293 |
+
repo = WorkflowRepository(db)
|
| 294 |
+
workflow = await repo.get_workflow_by_public_id(workflow_id, user_id)
|
| 295 |
+
if workflow is None:
|
| 296 |
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Workflow not found")
|
| 297 |
+
|
| 298 |
+
async def event_generator():
|
| 299 |
+
cursor = after_id
|
| 300 |
+
idle_ticks = 0
|
| 301 |
+
while True:
|
| 302 |
+
result = await db.execute(
|
| 303 |
+
select(Event)
|
| 304 |
+
.where(Event.workflow_id == workflow.id, Event.id > cursor)
|
| 305 |
+
.order_by(Event.id.asc())
|
| 306 |
+
)
|
| 307 |
+
new_events = result.scalars().all()
|
| 308 |
+
if new_events:
|
| 309 |
+
idle_ticks = 0
|
| 310 |
+
for event in new_events:
|
| 311 |
+
cursor = event.id
|
| 312 |
+
payload = {
|
| 313 |
+
"id": event.id,
|
| 314 |
+
"event_type": event.event_type,
|
| 315 |
+
"message": event.message,
|
| 316 |
+
"task_id": event.task_id,
|
| 317 |
+
"metadata": event.event_metadata,
|
| 318 |
+
"timestamp": event.timestamp.isoformat(),
|
| 319 |
+
}
|
| 320 |
+
yield f"data: {json.dumps(payload)}\n\n"
|
| 321 |
+
else:
|
| 322 |
+
idle_ticks += 1
|
| 323 |
+
|
| 324 |
+
wf_result = await db.execute(
|
| 325 |
+
select(Workflow.status).where(Workflow.id == workflow.id)
|
| 326 |
+
)
|
| 327 |
+
current_status = wf_result.scalar_one_or_none()
|
| 328 |
+
if current_status in TERMINAL_WORKFLOW_STATUSES and idle_ticks >= 3:
|
| 329 |
+
yield f"data: {json.dumps({'event_type': 'STREAM_END', 'message': 'Workflow finished'})}\n\n"
|
| 330 |
+
break
|
| 331 |
+
await asyncio.sleep(1)
|
| 332 |
+
|
| 333 |
+
return StreamingResponse(event_generator(), media_type="text/event-stream")
|
| 334 |
+
|
| 335 |
+
|
| 336 |
+
@router.get("/{workflow_id}/attempts", response_model=AttemptListResponse)
|
| 337 |
+
async def list_workflow_attempts(
|
| 338 |
+
workflow_id: str,
|
| 339 |
+
current_user: User = Depends(get_current_user),
|
| 340 |
+
repo: WorkflowRepository = Depends(get_repository),
|
| 341 |
+
db: AsyncSession = Depends(get_db),
|
| 342 |
+
) -> AttemptListResponse:
|
| 343 |
+
workflow = await repo.get_workflow_by_public_id(workflow_id, current_user.id)
|
| 344 |
+
if workflow is None:
|
| 345 |
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Workflow not found")
|
| 346 |
+
|
| 347 |
+
result = await db.execute(
|
| 348 |
+
select(Attempt)
|
| 349 |
+
.where(Attempt.workflow_id == workflow.id)
|
| 350 |
+
.order_by(Attempt.attempt_number.asc(), Attempt.id.asc())
|
| 351 |
+
)
|
| 352 |
+
attempts = result.scalars().all()
|
| 353 |
+
items = [
|
| 354 |
+
AttemptResponse(
|
| 355 |
+
id=attempt.id,
|
| 356 |
+
task_id=attempt.task_id,
|
| 357 |
+
attempt_number=attempt.attempt_number,
|
| 358 |
+
generated_code=attempt.generated_code,
|
| 359 |
+
generated_tests=attempt.generated_tests,
|
| 360 |
+
static_analysis_result=attempt.static_analysis_result,
|
| 361 |
+
stdout=attempt.stdout,
|
| 362 |
+
stderr=attempt.stderr,
|
| 363 |
+
exit_code=attempt.exit_code,
|
| 364 |
+
result=attempt.result,
|
| 365 |
+
error=attempt.error,
|
| 366 |
+
created_at=attempt.created_at,
|
| 367 |
+
)
|
| 368 |
+
for attempt in attempts
|
| 369 |
+
]
|
| 370 |
+
return AttemptListResponse(attempts=items, total=len(items))
|
AutoForge/backend/core/__init__.py
ADDED
|
File without changes
|
AutoForge/backend/core/__pycache__/__init__.cpython-312.pyc
ADDED
|
Binary file (161 Bytes). View file
|
|
|
AutoForge/backend/core/__pycache__/config.cpython-312.pyc
ADDED
|
Binary file (2.28 kB). View file
|
|
|
AutoForge/backend/core/__pycache__/dependencies.cpython-312.pyc
ADDED
|
Binary file (2.13 kB). View file
|
|
|
AutoForge/backend/core/__pycache__/encryption.cpython-312.pyc
ADDED
|
Binary file (1.99 kB). View file
|
|
|
AutoForge/backend/core/__pycache__/security.cpython-312.pyc
ADDED
|
Binary file (2.21 kB). View file
|
|
|
AutoForge/backend/core/config.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import tempfile
|
| 3 |
+
from functools import lru_cache
|
| 4 |
+
|
| 5 |
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def _default_workspace_root() -> str:
|
| 9 |
+
base = os.path.join(tempfile.gettempdir(), "autoforge", "workspaces")
|
| 10 |
+
os.makedirs(base, exist_ok=True)
|
| 11 |
+
return base
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class Settings(BaseSettings):
|
| 15 |
+
model_config = SettingsConfigDict(
|
| 16 |
+
env_file=".env",
|
| 17 |
+
env_file_encoding="utf-8",
|
| 18 |
+
case_sensitive=False,
|
| 19 |
+
extra="ignore",
|
| 20 |
+
)
|
| 21 |
+
|
| 22 |
+
database_url: str = "postgresql+asyncpg://autoforge:autoforge@localhost:5432/autoforge"
|
| 23 |
+
secret_key: str = "change-me-in-production-use-a-long-random-string"
|
| 24 |
+
encryption_key: str = "change-me-32-byte-base64-fernet-key-here="
|
| 25 |
+
algorithm: str = "HS256"
|
| 26 |
+
access_token_expire_minutes: int = 60 * 24
|
| 27 |
+
log_level: str = "INFO"
|
| 28 |
+
docker_host: str = "unix:///var/run/docker.sock"
|
| 29 |
+
max_retries: int = 4
|
| 30 |
+
workspace_root: str = _default_workspace_root()
|
| 31 |
+
default_timeout: int = 300
|
| 32 |
+
cors_origins: str = "http://localhost:3000,http://localhost:5173"
|
| 33 |
+
docker_image: str = "autoforge-sandbox:3.12"
|
| 34 |
+
docker_memory_limit: str = "512m"
|
| 35 |
+
docker_cpu_limit: float = 1.0
|
| 36 |
+
docker_network_disabled: bool = True
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
@lru_cache
|
| 40 |
+
def get_settings() -> Settings:
|
| 41 |
+
return Settings()
|
AutoForge/backend/core/dependencies.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import Depends, HTTPException, status
|
| 2 |
+
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
| 3 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 4 |
+
|
| 5 |
+
from backend.core.security import decode_access_token
|
| 6 |
+
from backend.db.models import User
|
| 7 |
+
from backend.db.session import get_db
|
| 8 |
+
from backend.memory.workflow_repository import WorkflowRepository
|
| 9 |
+
|
| 10 |
+
security = HTTPBearer(auto_error=False)
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
async def get_repository(db: AsyncSession = Depends(get_db)) -> WorkflowRepository:
|
| 14 |
+
return WorkflowRepository(db)
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
async def get_current_user(
|
| 18 |
+
credentials: HTTPAuthorizationCredentials | None = Depends(security),
|
| 19 |
+
repo: WorkflowRepository = Depends(get_repository),
|
| 20 |
+
) -> User:
|
| 21 |
+
if credentials is None or not credentials.credentials:
|
| 22 |
+
raise HTTPException(
|
| 23 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 24 |
+
detail="Not authenticated",
|
| 25 |
+
headers={"WWW-Authenticate": "Bearer"},
|
| 26 |
+
)
|
| 27 |
+
|
| 28 |
+
user_id_str = decode_access_token(credentials.credentials)
|
| 29 |
+
if user_id_str is None:
|
| 30 |
+
raise HTTPException(
|
| 31 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 32 |
+
detail="Invalid or expired token",
|
| 33 |
+
headers={"WWW-Authenticate": "Bearer"},
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
try:
|
| 37 |
+
user_id = int(user_id_str)
|
| 38 |
+
except ValueError as exc:
|
| 39 |
+
raise HTTPException(
|
| 40 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 41 |
+
detail="Invalid token subject",
|
| 42 |
+
) from exc
|
| 43 |
+
|
| 44 |
+
user = await repo.get_user_by_id(user_id)
|
| 45 |
+
if user is None:
|
| 46 |
+
raise HTTPException(
|
| 47 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 48 |
+
detail="User not found",
|
| 49 |
+
)
|
| 50 |
+
return user
|
AutoForge/backend/core/encryption.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import base64
|
| 2 |
+
import hashlib
|
| 3 |
+
|
| 4 |
+
from cryptography.fernet import Fernet, InvalidToken
|
| 5 |
+
|
| 6 |
+
from backend.core.config import get_settings
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def _fernet() -> Fernet:
|
| 10 |
+
settings = get_settings()
|
| 11 |
+
key_material = hashlib.sha256(settings.encryption_key.encode()).digest()
|
| 12 |
+
fernet_key = base64.urlsafe_b64encode(key_material)
|
| 13 |
+
return Fernet(fernet_key)
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def encrypt_value(value: str) -> str:
|
| 17 |
+
return _fernet().encrypt(value.encode()).decode()
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def decrypt_value(encrypted_value: str) -> str:
|
| 21 |
+
try:
|
| 22 |
+
return _fernet().decrypt(encrypted_value.encode()).decode()
|
| 23 |
+
except InvalidToken as exc:
|
| 24 |
+
raise ValueError("Failed to decrypt credential") from exc
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def redact_secret(value: str, visible_chars: int = 4) -> str:
|
| 28 |
+
if len(value) <= visible_chars:
|
| 29 |
+
return "*" * len(value)
|
| 30 |
+
return f"{'*' * (len(value) - visible_chars)}{value[-visible_chars:]}"
|
AutoForge/backend/core/security.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from datetime import UTC, datetime, timedelta
|
| 2 |
+
from typing import Any
|
| 3 |
+
|
| 4 |
+
from jose import JWTError, jwt
|
| 5 |
+
from passlib.context import CryptContext
|
| 6 |
+
|
| 7 |
+
from backend.core.config import get_settings
|
| 8 |
+
|
| 9 |
+
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def hash_password(password: str) -> str:
|
| 13 |
+
return pwd_context.hash(password)
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
| 17 |
+
return pwd_context.verify(plain_password, hashed_password)
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def create_access_token(subject: str, expires_delta: timedelta | None = None) -> str:
|
| 21 |
+
settings = get_settings()
|
| 22 |
+
expire = datetime.now(UTC) + (
|
| 23 |
+
expires_delta or timedelta(minutes=settings.access_token_expire_minutes)
|
| 24 |
+
)
|
| 25 |
+
payload: dict[str, Any] = {"sub": subject, "exp": expire}
|
| 26 |
+
return jwt.encode(payload, settings.secret_key, algorithm=settings.algorithm)
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def decode_access_token(token: str) -> str | None:
|
| 30 |
+
settings = get_settings()
|
| 31 |
+
try:
|
| 32 |
+
payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm])
|
| 33 |
+
subject = payload.get("sub")
|
| 34 |
+
return str(subject) if subject else None
|
| 35 |
+
except JWTError:
|
| 36 |
+
return None
|
AutoForge/backend/db/__init__.py
ADDED
|
File without changes
|
AutoForge/backend/db/__pycache__/__init__.cpython-312.pyc
ADDED
|
Binary file (159 Bytes). View file
|
|
|
AutoForge/backend/db/__pycache__/session.cpython-312.pyc
ADDED
|
Binary file (1.3 kB). View file
|
|
|