Skip to content

Commit 3151d62

Browse files
author
Ryan
committed
up timeouts
1 parent db53c6c commit 3151d62

10 files changed

Lines changed: 283 additions & 40 deletions

File tree

backend/app/api/routes/deployments.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
DeploymentUpdate,
2222
)
2323
from app.services.deploy import run_deployment, teardown_sandbox
24+
from app.services.uploads import get_upload_record
2425

2526
log = logging.getLogger(__name__)
2627

@@ -38,6 +39,13 @@ async def create_deployment(
3839
current_user: CurrentUser,
3940
background_tasks: BackgroundTasks,
4041
) -> Deployment:
42+
if payload.upload_id:
43+
record = get_upload_record(payload.upload_id)
44+
if record is None:
45+
raise HTTPException(status_code=422, detail="Upload not found")
46+
if record.owner_user_id != current_user.id:
47+
raise HTTPException(status_code=403, detail="Upload does not belong to this user")
48+
4149
deployment = Deployment(
4250
user_id=current_user.id,
4351
name=payload.name,

backend/app/api/routes/uploads.py

Lines changed: 29 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,51 @@
1-
import uuid
2-
from pathlib import Path
31
from typing import Annotated
42

5-
from fastapi import APIRouter, File, UploadFile
3+
from fastapi import APIRouter, File, HTTPException, UploadFile, status
64

5+
from app.api.deps import CurrentUser
76
from app.schemas.deployment import UploadResponse
7+
from app.services.uploads import (
8+
UPLOAD_MAX_BYTES,
9+
archive_path_for,
10+
new_upload_id,
11+
write_upload_metadata,
12+
)
813

914
router = APIRouter(prefix="/uploads", tags=["uploads"])
1015

11-
# Local-disk staging area. Swap for S3 later.
12-
UPLOAD_DIR = Path("uploads")
13-
UPLOAD_DIR.mkdir(exist_ok=True)
14-
1516

1617
@router.post("", response_model=UploadResponse)
17-
async def upload_code(file: Annotated[UploadFile, File(...)]) -> UploadResponse:
18+
async def upload_code(
19+
file: Annotated[UploadFile, File(...)],
20+
current_user: CurrentUser,
21+
) -> UploadResponse:
1822
"""Accept a tarball / zip of the user's project directory.
1923
2024
The CLI bundles the current folder, POSTs it here, and uses the returned
2125
`upload_id` when creating a deployment.
2226
"""
23-
upload_id = uuid.uuid4().hex
24-
suffix = Path(file.filename or "").suffix
25-
dest = UPLOAD_DIR / f"{upload_id}{suffix}"
27+
upload_id = new_upload_id()
28+
dest = archive_path_for(upload_id)
2629

2730
size = 0
2831
with dest.open("wb") as out:
2932
while chunk := await file.read(1024 * 1024):
30-
out.write(chunk)
3133
size += len(chunk)
34+
if size > UPLOAD_MAX_BYTES:
35+
out.close()
36+
dest.unlink(missing_ok=True)
37+
raise HTTPException(
38+
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
39+
detail=f"Upload too large (max {UPLOAD_MAX_BYTES // (1024 * 1024)} MB)",
40+
)
41+
out.write(chunk)
42+
write_upload_metadata(
43+
upload_id=upload_id,
44+
owner_user_id=current_user.id,
45+
original_filename=file.filename or dest.name,
46+
stored_filename=dest.name,
47+
size=size,
48+
)
3249

3350
return UploadResponse(
3451
upload_id=upload_id,

backend/app/core/config.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,10 @@ class Settings(BaseSettings):
4949
dedalus_api_key: str = ""
5050
anthropic_api_key: str = ""
5151

52+
# How long deployment sandboxes should live before Modal auto-terminates
53+
# them, unless explicitly torn down earlier.
54+
deployment_sandbox_ttl_hours: int = 6
55+
5256

5357
@lru_cache
5458
def get_settings() -> Settings:

backend/app/services/deploy.py

Lines changed: 35 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,8 @@
3232
import re
3333
import shlex
3434
import time
35-
import re
3635
from contextlib import contextmanager
36+
from pathlib import Path
3737
from typing import Any
3838

3939
from app.core.config import get_settings
@@ -69,6 +69,7 @@
6969
Sandbox,
7070
SandboxError,
7171
)
72+
from app.services.uploads import get_upload_record
7273

7374
log = logging.getLogger(__name__)
7475

@@ -308,6 +309,11 @@ def _clone_into_sync(sb: Sandbox, github_url: str) -> None:
308309
raise
309310

310311

312+
def _extract_upload_into_repo_sync(sb: Sandbox, archive_path: Path) -> None:
313+
"""Seed REPO_DIR from an uploaded tarball."""
314+
sb.extract_upload_archive(archive_path)
315+
316+
311317
def _repo_has(sb: Sandbox, relative_path: str) -> bool:
312318
res = sb.exec(
313319
f"test -f {shlex.quote(relative_path)}",
@@ -444,21 +450,21 @@ async def run_deployment(deployment_id: str) -> None:
444450
f"Starting deployment: source={github_url or upload_id} model={model}",
445451
)
446452

447-
if not github_url:
453+
if not github_url and not upload_id:
448454
await _update_deployment(
449455
deployment_id,
450456
status=DEPLOYMENT_STATUS_FAILED,
451-
error=(
452-
"Upload-based deployments not yet supported. "
453-
"Provide `github_url` instead."
454-
),
457+
error="Deployment source missing (expected github_url or upload_id).",
455458
)
456459
await _append_log(
457460
deployment_id,
458-
"ERROR: upload-based deployments not yet supported. Use github_url.",
461+
"ERROR: deployment source missing.",
462+
)
463+
dlog.warning(
464+
"deployment source missing (github_url=%s upload_id=%s)",
465+
github_url,
466+
upload_id,
459467
)
460-
dlog.warning("upload-based deployments not supported (upload_id=%s); failing fast",
461-
upload_id)
462468
return
463469

464470
sb: Sandbox | None = None
@@ -479,10 +485,25 @@ async def run_deployment(deployment_id: str) -> None:
479485
dlog.info("sandbox acquired: id=%s", sb.object_id)
480486
await _append_log(deployment_id, f"Sandbox ready: {sb.object_id}")
481487

482-
await _append_log(deployment_id, f"Cloning {github_url}...")
483-
with _step(dlog, "clone repo"):
484-
await asyncio.to_thread(_clone_into_sync, sb, github_url)
485-
await _append_log(deployment_id, "Repo cloned.")
488+
source_description = ""
489+
if github_url:
490+
await _append_log(deployment_id, f"Cloning {github_url}...")
491+
with _step(dlog, "clone repo"):
492+
await asyncio.to_thread(_clone_into_sync, sb, github_url)
493+
await _append_log(deployment_id, "Repo cloned.")
494+
source_description = f"GitHub repo: {github_url}"
495+
else:
496+
record = get_upload_record(upload_id or "")
497+
if record is None:
498+
raise RuntimeError(f"Upload {upload_id} is missing from upload storage")
499+
await _append_log(
500+
deployment_id,
501+
f"Preparing uploaded source {record.upload_id} ({record.original_filename})...",
502+
)
503+
with _step(dlog, "extract uploaded archive", upload_id=record.upload_id):
504+
await asyncio.to_thread(_extract_upload_into_repo_sync, sb, record.archive_path)
505+
await _append_log(deployment_id, "Uploaded project extracted.")
506+
source_description = f"Uploaded archive: {record.upload_id}"
486507

487508
# ------------------------------------------------------------------
488509
# 2. Agent #1 — analyze (with heuristic fast-path)
@@ -528,7 +549,7 @@ async def run_deployment(deployment_id: str) -> None:
528549
f"Heuristic uncertain; running Agent #1 (analyze) with {model}...",
529550
)
530551
analyze_user = render_analyze_user(
531-
source_description=f"GitHub repo: {github_url}",
552+
source_description=source_description,
532553
name=name,
533554
user_env_keys=env_keys,
534555
)

backend/app/services/sandbox.py

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -213,12 +213,14 @@ def object_id(self) -> str:
213213
# ------------------------------------------------------------------
214214

215215
@classmethod
216-
def create(cls, *, timeout_s: int = 30 * 60) -> "Sandbox":
216+
def create(cls, *, timeout_s: int | None = None) -> "Sandbox":
217217
# LLM keys are injected at runtime as env vars via a Modal Secret so
218218
# OpenClaw's `${ANTHROPIC_API_KEY}` substitution in the mounted
219219
# config.json resolves correctly. Keeping the keys out of the image
220220
# means key rotation doesn't trigger an image rebuild.
221221
settings = get_settings()
222+
if timeout_s is None:
223+
timeout_s = max(1, settings.deployment_sandbox_ttl_hours) * 60 * 60
222224
secret_vars: dict[str, str] = {}
223225
if settings.anthropic_api_key:
224226
secret_vars["ANTHROPIC_API_KEY"] = settings.anthropic_api_key
@@ -408,6 +410,30 @@ def wait_for_repo(self, *, max_wait_s: int = 120) -> None:
408410
int((time.perf_counter() - t0) * 1000),
409411
size.stdout.strip() or "?")
410412

413+
def extract_upload_archive(self, local_archive_path: str | Path) -> None:
414+
"""Copy a local tarball into the sandbox and extract it to REPO_DIR."""
415+
local_path = Path(local_archive_path)
416+
if not local_path.exists():
417+
raise SandboxError(f"upload archive does not exist: {local_path}")
418+
419+
remote_path = "/tmp/dploy-upload.tar.gz"
420+
log.info("copying upload archive into sandbox: %s -> %s", local_path, remote_path)
421+
try:
422+
self._sb.filesystem.copy_from_local(str(local_path), remote_path)
423+
except Exception as e:
424+
raise SandboxError(f"failed copying upload archive to sandbox: {e}") from e
425+
426+
self.check_exec(
427+
f"rm -rf {shlex.quote(REPO_DIR)} && "
428+
f"mkdir -p {shlex.quote(REPO_DIR)} && "
429+
f"tar -xzf {shlex.quote(remote_path)} -C {shlex.quote(REPO_DIR)} "
430+
"--no-same-owner --no-same-permissions && "
431+
f"rm -f {shlex.quote(remote_path)}",
432+
timeout_s=120,
433+
stdout_limit=200_000,
434+
stderr_limit=200_000,
435+
)
436+
411437
# ------------------------------------------------------------------
412438
# OpenClaw chat
413439
# ------------------------------------------------------------------

backend/app/services/sandbox_pool.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@ def _provision_warm_sb(model: str) -> Sandbox:
136136
If the warmup chat fails we still return the sandbox — it's usable,
137137
just slower on first real use. We log a warning so it shows up.
138138
"""
139-
sb = Sandbox.create(timeout_s=30 * 60)
139+
sb = Sandbox.create()
140140
try:
141141
sb.set_model(model)
142142
sb.start_gateway()

backend/app/services/uploads.py

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
from __future__ import annotations
2+
3+
import json
4+
import uuid
5+
from dataclasses import dataclass
6+
from datetime import UTC, datetime
7+
from pathlib import Path
8+
9+
UPLOAD_DIR = Path("uploads")
10+
UPLOAD_DIR.mkdir(exist_ok=True)
11+
12+
UPLOAD_MAX_BYTES = 200 * 1024 * 1024 # 200 MB hard cap
13+
14+
15+
@dataclass(frozen=True)
16+
class UploadRecord:
17+
upload_id: str
18+
owner_user_id: str
19+
original_filename: str
20+
stored_filename: str
21+
archive_path: Path
22+
metadata_path: Path
23+
size: int
24+
created_at: str
25+
26+
27+
def new_upload_id() -> str:
28+
return uuid.uuid4().hex
29+
30+
31+
def archive_path_for(upload_id: str) -> Path:
32+
# Stable extension keeps extraction logic simple and avoids suffix guessing.
33+
return UPLOAD_DIR / f"{upload_id}.tar.gz"
34+
35+
36+
def metadata_path_for(upload_id: str) -> Path:
37+
return UPLOAD_DIR / f"{upload_id}.meta.json"
38+
39+
40+
def write_upload_metadata(
41+
*,
42+
upload_id: str,
43+
owner_user_id: str,
44+
original_filename: str,
45+
stored_filename: str,
46+
size: int,
47+
) -> Path:
48+
meta = {
49+
"upload_id": upload_id,
50+
"owner_user_id": owner_user_id,
51+
"original_filename": original_filename,
52+
"stored_filename": stored_filename,
53+
"size": size,
54+
"created_at": datetime.now(UTC).isoformat().replace("+00:00", "Z"),
55+
}
56+
meta_path = metadata_path_for(upload_id)
57+
meta_path.write_text(json.dumps(meta), encoding="utf-8")
58+
return meta_path
59+
60+
61+
def get_upload_record(upload_id: str) -> UploadRecord | None:
62+
archive_path = archive_path_for(upload_id)
63+
meta_path = metadata_path_for(upload_id)
64+
if not archive_path.exists() or not meta_path.exists():
65+
return None
66+
try:
67+
meta = json.loads(meta_path.read_text(encoding="utf-8"))
68+
except Exception:
69+
return None
70+
owner_user_id = str(meta.get("owner_user_id") or "").strip()
71+
if not owner_user_id:
72+
return None
73+
original_filename = str(meta.get("original_filename") or archive_path.name)
74+
stored_filename = str(meta.get("stored_filename") or archive_path.name)
75+
size = int(meta.get("size") or archive_path.stat().st_size)
76+
created_at = str(meta.get("created_at") or "")
77+
return UploadRecord(
78+
upload_id=upload_id,
79+
owner_user_id=owner_user_id,
80+
original_filename=original_filename,
81+
stored_filename=stored_filename,
82+
archive_path=archive_path,
83+
metadata_path=meta_path,
84+
size=size,
85+
created_at=created_at,
86+
)

0 commit comments

Comments
 (0)