Skip to content

Commit e32d8f0

Browse files
author
Ryan
committed
in progress
Made-with: Cursor
1 parent afc0661 commit e32d8f0

11 files changed

Lines changed: 546 additions & 163 deletions

File tree

.gitignore

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,4 +56,9 @@ backend/app.db
5656
backend/app.db-journal
5757
backend/uploads/
5858

59-
.claude/
59+
.claude/
60+
61+
# Cloudflare Wrangler local state (account id, project cache, dev secrets)
62+
.wrangler/
63+
.dev.vars
64+
.dev.vars.*

backend/_modal.py

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
"""Modal deployment for the dploy backend.
2+
3+
Deploy with:
4+
5+
cd backend && modal deploy _modal.py
6+
7+
Architecture
8+
------------
9+
The FastAPI app runs on Modal as a single-instance ASGI app. Single instance
10+
is intentional: `app.services.sandbox_pool` (the warm Modal-sandbox pool) and
11+
the WebSocket terminal session registry both live in process memory. Multiple
12+
containers would split that state and break sandbox lookup mid-deploy.
13+
14+
Throughput is gated by `@modal.concurrent(max_inputs=100)` — one container
15+
serves up to 100 concurrent HTTP/WS requests, plenty for current load.
16+
17+
State
18+
-----
19+
A Modal Volume is mounted at `/data` and used for the SQLite database. The
20+
Secret sets `DATABASE_URL=sqlite+aiosqlite:////data/app.db` so the URL
21+
survives container restarts/redeploys. (Switch to managed Postgres later if
22+
you need backups, replicas, or zero-downtime deploys.)
23+
24+
Note: `backend/uploads/` is currently in-container only. It's not wired up
25+
to the deploy flow yet (`upload-based deployments not yet supported`), so
26+
losing it on restart is a no-op for now. When uploads ship, repoint
27+
`UPLOAD_DIR` at `/data/uploads`.
28+
29+
Modal credentials
30+
-----------------
31+
This function runs *inside* Modal, so `modal.Sandbox.create()` authenticates
32+
implicitly via the workspace identity — no `MODAL_TOKEN_*` env vars needed.
33+
34+
Secrets (one-time setup)
35+
------------------------
36+
Create a Modal Secret named `dploy-backend` before the first deploy:
37+
38+
modal secret create dploy-backend \\
39+
ANTHROPIC_API_KEY=... \\
40+
GITHUB_CLIENT_ID=... \\
41+
GITHUB_CLIENT_SECRET=... \\
42+
SESSION_SECRET="$(python -c 'import secrets; print(secrets.token_urlsafe(48))')" \\
43+
FRONTEND_URL=https://dploy.ryantanen.com \\
44+
GITHUB_REDIRECT_URI=https://api.dploy.ryantanen.com/api/v1/auth/github/callback \\
45+
CORS_ORIGINS='["https://dploy.ryantanen.com"]' \\
46+
SESSION_COOKIE_SECURE=true \\
47+
DATABASE_URL=sqlite+aiosqlite:////data/app.db
48+
"""
49+
50+
import modal
51+
52+
app = modal.App(name="dploy-backend")
53+
54+
# Image build:
55+
# 1. `pip_install_from_pyproject` reads ./pyproject.toml at deploy time and
56+
# installs the runtime deps. Cached across deploys unless pyproject changes.
57+
# 2. `add_local_python_source("app")` ships the `app/` package (including
58+
# non-Python files like openclaw.json) into the image at deploy time.
59+
# This is the bit that was missing — without it, `from app.main import
60+
# create_app` fails with ModuleNotFoundError.
61+
image = (
62+
modal.Image.debian_slim(python_version="3.13")
63+
.pip_install_from_pyproject("pyproject.toml")
64+
.add_local_dir("app", remote_path="/root/app")
65+
)
66+
67+
volume = modal.Volume.from_name("dploy-backend-data", create_if_missing=True)
68+
69+
70+
@app.function(
71+
image=image,
72+
secrets=[modal.Secret.from_name("dploy-secrets")],
73+
volumes={"/data": volume},
74+
# Pin to one container — sandbox_pool + WS terminal sessions are in-memory.
75+
min_containers=1,
76+
max_containers=1,
77+
# WebSocket terminal sessions can stay open for a long time.
78+
timeout=60 * 60,
79+
# Don't aggressively idle the container out — keeps the warm sandbox
80+
# pool alive between bursts of traffic.
81+
scaledown_window=60 * 30,
82+
)
83+
@modal.concurrent(max_inputs=100)
84+
@modal.asgi_app()
85+
def fastapi_app():
86+
from app.main import create_app
87+
return create_app()

backend/app/models/__init__.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,15 @@
77
)
88
from app.models.session import Session
99
from app.models.user import User
10+
from app.models.warm_sandbox import (
11+
WARM_ALIVE_STATUSES,
12+
WARM_STATUSES,
13+
WARM_STATUS_CLAIMED,
14+
WARM_STATUS_FAILED,
15+
WARM_STATUS_READY,
16+
WARM_STATUS_WARMING,
17+
WarmSandbox,
18+
)
1019

1120
__all__ = [
1221
"AGENT_KINDS",
@@ -16,4 +25,11 @@
1625
"Deployment",
1726
"Session",
1827
"User",
28+
"WARM_ALIVE_STATUSES",
29+
"WARM_STATUSES",
30+
"WARM_STATUS_CLAIMED",
31+
"WARM_STATUS_FAILED",
32+
"WARM_STATUS_READY",
33+
"WARM_STATUS_WARMING",
34+
"WarmSandbox",
1935
]

backend/app/models/warm_sandbox.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
"""Persistent state for the warm sandbox pool.
2+
3+
Each row tracks one Modal sandbox that's been pre-provisioned (gateway up,
4+
model set) and is either still being warmed, ready to claim, or already
5+
claimed by a deployment.
6+
"""
7+
8+
import uuid
9+
from datetime import datetime
10+
from typing import Final
11+
12+
from sqlalchemy import DateTime, Index, String, Text
13+
from sqlalchemy.orm import Mapped, mapped_column
14+
15+
from app.db.base import Base
16+
17+
WARM_STATUS_WARMING: Final = "warming"
18+
WARM_STATUS_READY: Final = "ready"
19+
WARM_STATUS_CLAIMED: Final = "claimed"
20+
WARM_STATUS_FAILED: Final = "failed"
21+
22+
WARM_STATUSES: Final = (
23+
WARM_STATUS_WARMING,
24+
WARM_STATUS_READY,
25+
WARM_STATUS_CLAIMED,
26+
WARM_STATUS_FAILED,
27+
)
28+
29+
WARM_ALIVE_STATUSES: Final = (WARM_STATUS_WARMING, WARM_STATUS_READY)
30+
31+
32+
def _new_id() -> str:
33+
return uuid.uuid4().hex
34+
35+
36+
class WarmSandbox(Base):
37+
__tablename__ = "warm_sandboxes"
38+
39+
# Local slot id. Stable from the moment _replenish() reserves a slot,
40+
# before we know the Modal sandbox_id.
41+
id: Mapped[str] = mapped_column(String(32), primary_key=True, default=_new_id)
42+
43+
# Modal sandbox object id. NULL while warming; set when status flips to
44+
# `ready`. Indexed because the diagnostics page joins by it.
45+
sandbox_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
46+
47+
model: Mapped[str] = mapped_column(String(128), nullable=False)
48+
status: Mapped[str] = mapped_column(String(16), nullable=False, default=WARM_STATUS_WARMING)
49+
50+
# `created_at` / `updated_at` come from Base. We add explicit ready/claimed
51+
# timestamps so the diagnostics endpoint can show how long warmup took
52+
# and how stale a claim is.
53+
ready_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
54+
claimed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
55+
56+
error: Mapped[str | None] = mapped_column(Text, nullable=True)
57+
58+
__table_args__ = (
59+
# Hot path: "find me a ready sandbox for this model, oldest first".
60+
Index(
61+
"ix_warm_sandboxes_status_model_created",
62+
"status", "model", "created_at",
63+
),
64+
)
65+
66+
67+
__all__ = [
68+
"WarmSandbox",
69+
"WARM_STATUS_WARMING",
70+
"WARM_STATUS_READY",
71+
"WARM_STATUS_CLAIMED",
72+
"WARM_STATUS_FAILED",
73+
"WARM_STATUSES",
74+
"WARM_ALIVE_STATUSES",
75+
]

0 commit comments

Comments
 (0)