NOT-OMEGA commited on
Commit
8d6714e
·
verified ·
1 Parent(s): 91c3a87

Upload 144 files

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. AutoForge/.env.example +33 -0
  2. AutoForge/.gitignore +13 -0
  3. AutoForge/Dockerfile +27 -0
  4. AutoForge/HUGGINGFACE_DEPLOYMENT.md +47 -0
  5. AutoForge/README.md +212 -0
  6. AutoForge/backend/__init__.py +0 -0
  7. AutoForge/backend/agents/__init__.py +1 -0
  8. AutoForge/backend/agents/__pycache__/__init__.cpython-312.pyc +0 -0
  9. AutoForge/backend/agents/__pycache__/code_agent.cpython-312.pyc +0 -0
  10. AutoForge/backend/agents/__pycache__/diagnosis_agent.cpython-312.pyc +0 -0
  11. AutoForge/backend/agents/__pycache__/planner.cpython-312.pyc +0 -0
  12. AutoForge/backend/agents/__pycache__/repair_agent.cpython-312.pyc +0 -0
  13. AutoForge/backend/agents/__pycache__/task_handlers.cpython-312.pyc +0 -0
  14. AutoForge/backend/agents/__pycache__/test_agent.cpython-312-pytest-9.1.1.pyc +0 -0
  15. AutoForge/backend/agents/__pycache__/test_agent.cpython-312.pyc +0 -0
  16. AutoForge/backend/agents/code_agent.py +95 -0
  17. AutoForge/backend/agents/diagnosis_agent.py +81 -0
  18. AutoForge/backend/agents/planner.py +91 -0
  19. AutoForge/backend/agents/repair_agent.py +101 -0
  20. AutoForge/backend/agents/task_handlers.py +101 -0
  21. AutoForge/backend/agents/test_agent.py +94 -0
  22. AutoForge/backend/analysis/__init__.py +1 -0
  23. AutoForge/backend/analysis/__pycache__/__init__.cpython-312.pyc +0 -0
  24. AutoForge/backend/analysis/__pycache__/static_analyzer.cpython-312.pyc +0 -0
  25. AutoForge/backend/analysis/static_analyzer.py +110 -0
  26. AutoForge/backend/api/__init__.py +0 -0
  27. AutoForge/backend/api/__pycache__/__init__.cpython-312.pyc +0 -0
  28. AutoForge/backend/api/__pycache__/auth.cpython-312.pyc +0 -0
  29. AutoForge/backend/api/__pycache__/providers.cpython-312.pyc +0 -0
  30. AutoForge/backend/api/__pycache__/schemas.cpython-312.pyc +0 -0
  31. AutoForge/backend/api/__pycache__/settings.cpython-312.pyc +0 -0
  32. AutoForge/backend/api/__pycache__/workflows.cpython-312.pyc +0 -0
  33. AutoForge/backend/api/auth.py +50 -0
  34. AutoForge/backend/api/providers.py +36 -0
  35. AutoForge/backend/api/schemas.py +146 -0
  36. AutoForge/backend/api/settings.py +73 -0
  37. AutoForge/backend/api/workflows.py +370 -0
  38. AutoForge/backend/core/__init__.py +0 -0
  39. AutoForge/backend/core/__pycache__/__init__.cpython-312.pyc +0 -0
  40. AutoForge/backend/core/__pycache__/config.cpython-312.pyc +0 -0
  41. AutoForge/backend/core/__pycache__/dependencies.cpython-312.pyc +0 -0
  42. AutoForge/backend/core/__pycache__/encryption.cpython-312.pyc +0 -0
  43. AutoForge/backend/core/__pycache__/security.cpython-312.pyc +0 -0
  44. AutoForge/backend/core/config.py +41 -0
  45. AutoForge/backend/core/dependencies.py +50 -0
  46. AutoForge/backend/core/encryption.py +30 -0
  47. AutoForge/backend/core/security.py +36 -0
  48. AutoForge/backend/db/__init__.py +0 -0
  49. AutoForge/backend/db/__pycache__/__init__.cpython-312.pyc +0 -0
  50. 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