Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,8 +1,21 @@
# Rename this file to `.env` and replace placeholders with your actual values.
OPENAI_API_KEY=your_openai_api_key_here
OPENAI_API_KEY=
OPENAI_MODEL_NAME=gpt-4o-mini
HOST=0.0.0.0
PORT=8000

CORS_ORIGINS=["http://localhost:3000","http://localhost:8501"]
MAX_REQUEST_CHARACTERS=20000
MAX_ATTEMPTS=3
MAX_ACTIVE_RUNS=1
MAX_DAILY_MODEL_RUNS=20
RATE_LIMIT_REQUESTS=10
RATE_LIMIT_WINDOW_SECONDS=60
RUN_TIMEOUT_SECONDS=300

SANDBOX_BACKEND=docker
DOCKER_SANDBOX_IMAGE=digital-forge-sandbox:py311
MODAL_SANDBOX_APP=digital-forge-sandbox
SANDBOX_TIMEOUT_SECONDS=10
SANDBOX_MEMORY_MIB=256
SANDBOX_CPU_CORES=1
SANDBOX_PROCESS_LIMIT=64
13 changes: 12 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,15 @@ npm run dev

The frontend defaults to `http://localhost:3000` and calls the backend at `http://localhost:8000`. Override that with `NEXT_PUBLIC_BACKEND_URL` if needed.

## Deployment

Day 7 deployment is configured but not performed by this branch. See
`docs/DEPLOYMENT.md` for the Vercel, Render, and Modal checklist.

The hosted backend should run with `SANDBOX_BACKEND=modal`, one active public run,
per-client rate limits, a daily process-local model-run budget, and a workflow-boundary
timeout. Set `NEXT_PUBLIC_BACKEND_URL` in Vercel to the deployed Render backend URL.

## Running Benchmarks

Zero-shot baseline:
Expand Down Expand Up @@ -166,13 +175,15 @@ cd frontend && npm run lint && npm run typecheck && npm run build

- Run snapshots are process-local and disappear when the backend restarts.
- Cancellation is cooperative and stops at workflow boundaries.
- Public deployment rate limits, spend controls, durable run storage, and one-run concurrency enforcement remain deployment work.
- Public deployment controls are process-local. They protect the free demo from casual
overuse but reset when the Render process restarts or scales.
- Per-agent token and cost telemetry is not yet captured by the benchmark runner.
- The RAG layer is evaluated separately from the active 20-task algorithm benchmark.

## Project Docs

- `docs/STATUS.md`: current implementation status, verification, risks, and handoff notes.
- `docs/DEPLOYMENT.md`: public deployment configuration and smoke-test checklist.
- `docs/ARCHITECTURE.md`: system architecture and deployment model.
- `docs/DECISIONS.md`: accepted design decisions.
- `docs/WEEK_PLAN.md`: phased revamp plan.
Expand Down
5 changes: 5 additions & 0 deletions backend/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ class Settings(BaseSettings):
cors_origins: list[str] = ["http://localhost:3000", "http://localhost:8501"]
max_request_characters: int = Field(default=20_000, ge=1)
max_attempts: int = Field(default=3, ge=1, le=3)
max_active_runs: int = Field(default=1, ge=1, le=1)
max_daily_model_runs: int = Field(default=20, ge=1, le=100)
rate_limit_requests: int = Field(default=10, ge=1, le=100)
rate_limit_window_seconds: float = Field(default=60.0, gt=0, le=3600)
run_timeout_seconds: float = Field(default=300.0, gt=0, le=900)
sandbox_backend: Literal["docker", "modal"] = "docker"
docker_sandbox_image: str = "digital-forge-sandbox:py311"
modal_sandbox_app: str = "digital-forge-sandbox"
Expand Down
87 changes: 76 additions & 11 deletions backend/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@

import argparse
from collections.abc import Sequence
from threading import RLock
from time import monotonic
from uuid import UUID, uuid4

import uvicorn
from fastapi import FastAPI, HTTPException
from fastapi import Depends, FastAPI, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware

from benchmark.models import BenchmarkReport
Expand All @@ -14,14 +16,42 @@
from .config import Settings, get_settings
from .models import RunRequest, RunResponse, RunSnapshot
from .run_manager import (
ActiveRunLimitExceeded,
CancellationCheck,
DailyRunLimitExceeded,
RunManager,
Runner,
RunnerFactory,
UpdateCallback,
)


class RateLimiter:
"""Small process-local limiter for the public demo API."""

def __init__(self, request_limit: int, window_seconds: float):
self.request_limit = request_limit
self.window_seconds = window_seconds
self._requests: dict[str, list[float]] = {}
self._lock = RLock()

def allow(self, key: str) -> bool:
now = monotonic()
window_start = now - self.window_seconds
with self._lock:
timestamps = [
timestamp
for timestamp in self._requests.get(key, [])
if timestamp >= window_start
]
if len(timestamps) >= self.request_limit:
self._requests[key] = timestamps
return False
timestamps.append(now)
self._requests[key] = timestamps
return True


def _default_runner(
request: str,
settings: Settings,
Expand Down Expand Up @@ -49,6 +79,10 @@ def create_app(
app = FastAPI(title="The Digital Forge", version="0.1.0")
run_manager = RunManager(app_settings, create_runner)
app.state.run_manager = run_manager
app.state.rate_limiter = RateLimiter(
app_settings.rate_limit_requests,
app_settings.rate_limit_window_seconds,
)
app.add_middleware(
CORSMiddleware,
allow_origins=app_settings.cors_origins,
Expand All @@ -61,23 +95,54 @@ def create_app(
def health() -> dict[str, str]:
return {"status": "ok"}

def enforce_rate_limit(request: Request) -> None:
client = request.client.host if request.client else "unknown"
if not app.state.rate_limiter.allow(client):
raise HTTPException(status_code=429, detail="Rate limit exceeded.")

@app.post("/run", response_model=RunResponse)
def run_pipeline(payload: RunRequest) -> RunResponse:
def run_pipeline(
payload: RunRequest, _rate_limit: None = Depends(enforce_rate_limit)
) -> RunResponse:
if len(payload.request) > app_settings.max_request_characters:
raise HTTPException(status_code=413, detail="Request is too large.")
return create_runner(
payload.request,
app_settings,
uuid4(),
lambda _state: None,
lambda: False,
).run()
try:
run_manager.reserve_external_run()
except ActiveRunLimitExceeded:
raise HTTPException(
status_code=409, detail="Another run is already active."
) from None
except DailyRunLimitExceeded:
raise HTTPException(
status_code=429, detail="Daily model run limit exceeded."
) from None
try:
return create_runner(
payload.request,
app_settings,
uuid4(),
lambda _state: None,
lambda: False,
).run()
finally:
run_manager.release_external_run()

@app.post("/runs", response_model=RunSnapshot, status_code=202)
def start_run(payload: RunRequest) -> RunSnapshot:
def start_run(
payload: RunRequest, _rate_limit: None = Depends(enforce_rate_limit)
) -> RunSnapshot:
if len(payload.request) > app_settings.max_request_characters:
raise HTTPException(status_code=413, detail="Request is too large.")
return run_manager.start(payload.request)
try:
return run_manager.start(payload.request)
except ActiveRunLimitExceeded:
raise HTTPException(
status_code=409, detail="Another run is already active."
) from None
except DailyRunLimitExceeded:
raise HTTPException(
status_code=429, detail="Daily model run limit exceeded."
) from None

@app.get("/runs/{run_id}", response_model=RunSnapshot)
def get_run(run_id: UUID) -> RunSnapshot:
Expand Down
48 changes: 47 additions & 1 deletion backend/run_manager.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
"""Thread-safe in-memory run coordination for the polling API."""

from collections.abc import Callable
from threading import Event, RLock, Thread
from datetime import date
from threading import Event, RLock, Thread, Timer
from typing import Protocol
from uuid import UUID, uuid4

Expand Down Expand Up @@ -30,6 +31,14 @@ def run(self) -> RunResponse: ...
]


class ActiveRunLimitExceeded(Exception):
"""Raised when the public demo already has a run in progress."""


class DailyRunLimitExceeded(Exception):
"""Raised when process-local model-backed run budget is exhausted."""


class RunManager:
"""Own process-local run snapshots and execute pipelines in worker threads."""

Expand All @@ -38,6 +47,9 @@ def __init__(self, settings: Settings, runner_factory: RunnerFactory):
self.runner_factory = runner_factory
self._runs: dict[UUID, RunSnapshot] = {}
self._cancellations: dict[UUID, Event] = {}
self._external_active_runs = 0
self._daily_run_count = 0
self._daily_run_date = date.today()
self._lock = RLock()

def start(self, request: str) -> RunSnapshot:
Expand All @@ -56,6 +68,7 @@ def start(self, request: str) -> RunSnapshot:
)
cancellation = Event()
with self._lock:
self._reserve_run_locked()
self._runs[run_id] = snapshot
self._cancellations[run_id] = cancellation
Thread(
Expand All @@ -66,6 +79,15 @@ def start(self, request: str) -> RunSnapshot:
).start()
return snapshot.model_copy(deep=True)

def reserve_external_run(self) -> None:
with self._lock:
self._reserve_run_locked()
self._external_active_runs += 1

def release_external_run(self) -> None:
with self._lock:
self._external_active_runs = max(0, self._external_active_runs - 1)

def get(self, run_id: UUID) -> RunSnapshot | None:
with self._lock:
snapshot = self._runs.get(run_id)
Expand All @@ -90,6 +112,9 @@ def cancel(self, run_id: UUID) -> RunSnapshot | None:
return snapshot.model_copy(deep=True)

def _execute(self, run_id: UUID, request: str, cancellation: Event) -> None:
timer = Timer(self.settings.run_timeout_seconds, cancellation.set)
timer.daemon = True
timer.start()
try:
runner = self.runner_factory(
request,
Expand Down Expand Up @@ -133,6 +158,8 @@ def _execute(self, run_id: UUID, request: str, cancellation: Event) -> None:
),
}
)
finally:
timer.cancel()

def _update(self, run_id: UUID, state: RunState) -> None:
artifacts: list[RunArtifact] = []
Expand Down Expand Up @@ -174,3 +201,22 @@ def _update(self, run_id: UUID, state: RunState) -> None:
"updated_at": utc_now(),
}
)

def _reset_daily_count_if_needed(self) -> None:
today = date.today()
if today != self._daily_run_date:
self._daily_run_date = today
self._daily_run_count = 0

def _reserve_run_locked(self) -> None:
self._reset_daily_count_if_needed()
active_runs = self._external_active_runs + sum(
1
for existing in self._runs.values()
if existing.status in {RunStatus.pending, RunStatus.running}
)
if active_runs >= self.settings.max_active_runs:
raise ActiveRunLimitExceeded
if self._daily_run_count >= self.settings.max_daily_model_runs:
raise DailyRunLimitExceeded
self._daily_run_count += 1
64 changes: 64 additions & 0 deletions docs/DEPLOYMENT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# Deployment

Day 7 deployment uses Vercel for the Next.js frontend, Render Free for the FastAPI
backend, and Modal for hosted sandbox execution. Do not run a public benchmark from the
hosted demo; benchmark execution remains local or CI-only.

## Backend on Render

Use `render.yaml` as the backend blueprint. Set these environment variables in Render:

```text
OPENAI_API_KEY=<secret>
OPENAI_MODEL_NAME=gpt-4o-mini
CORS_ORIGINS=["https://<vercel-project>.vercel.app"]
SANDBOX_BACKEND=modal
MODAL_SANDBOX_APP=digital-forge-sandbox
MAX_ACTIVE_RUNS=1
MAX_DAILY_MODEL_RUNS=20
RATE_LIMIT_REQUESTS=10
RATE_LIMIT_WINDOW_SECONDS=60
RUN_TIMEOUT_SECONDS=300
```

The public backend is intentionally process-local: it allows one active run, applies a
small per-client rate limit, cancels at workflow boundaries after the configured timeout,
and stops accepting new model-backed runs after the daily process-local run budget is
exhausted.

## Frontend on Vercel

Deploy `frontend/` as the Vercel project root. Set:

```text
NEXT_PUBLIC_BACKEND_URL=https://<render-service>.onrender.com
```

The frontend build should run `npm ci` and `npm run build`.

## Modal Sandbox

Render must use `SANDBOX_BACKEND=modal` because Render Free is not a Docker host. The
Modal path builds the sandbox image from the same pinned offline capability set used by
Docker.

Authenticate Modal in the Render environment before live runs. Without Modal credentials,
the backend health check can pass but generated-code execution will fail as an
infrastructure configuration error.

## Smoke Tests

After deployment, verify:

```bash
curl -fsS https://<render-service>.onrender.com/health
curl -fsS https://<render-service>.onrender.com/benchmarks
```

Then open the Vercel URL and verify:

- Backend health shows connected after Render cold start.
- The benchmark dashboard loads tracked reports.
- A second submitted run while one is active returns `409`.
- Excess repeated submissions return `429`.
- One small paid live run reaches a terminal state through Modal before sharing the demo.
Loading
Loading