modal-devin 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,33 @@
1
+ """Run Devin Outposts workers on Modal."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ from modal_devin._config import WorkerSettings
6
+ from modal_devin._exceptions import (
7
+ ConfigurationError,
8
+ ModalCompatibilityError,
9
+ ModalDevinError,
10
+ OutpostsAPIError,
11
+ OutpostsProtocolError,
12
+ SessionStatusUnknownError,
13
+ WorkerExitedError,
14
+ )
15
+ from modal_devin.worker import Worker
16
+
17
+ try:
18
+ __version__ = version("modal-devin")
19
+ except PackageNotFoundError: # pragma: no cover - source trees without installed metadata
20
+ __version__ = "0+unknown"
21
+
22
+ __all__ = [
23
+ "ConfigurationError",
24
+ "ModalCompatibilityError",
25
+ "ModalDevinError",
26
+ "OutpostsAPIError",
27
+ "OutpostsProtocolError",
28
+ "SessionStatusUnknownError",
29
+ "Worker",
30
+ "WorkerExitedError",
31
+ "WorkerSettings",
32
+ "__version__",
33
+ ]
@@ -0,0 +1,5 @@
1
+ """Support ``python -m modal_devin``."""
2
+
3
+ from modal_devin.cli import run
4
+
5
+ run()
modal_devin/_client.py ADDED
@@ -0,0 +1,224 @@
1
+ """Typed boundary around the Devin Outposts HTTP API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import urllib.error
7
+ import urllib.parse
8
+ import urllib.request
9
+ from collections.abc import Iterable, Mapping
10
+ from dataclasses import dataclass
11
+ from enum import StrEnum
12
+ from types import TracebackType
13
+ from typing import Protocol, TypeAlias, cast
14
+
15
+ from modal_devin._exceptions import OutpostsAPIError, OutpostsProtocolError
16
+
17
+ JsonValue: TypeAlias = str | int | float | bool | None | list["JsonValue"] | dict[str, "JsonValue"]
18
+ JsonObject: TypeAlias = dict[str, JsonValue]
19
+ _MAX_RESPONSE_BYTES = 1024 * 1024
20
+
21
+
22
+ class HTTPResponse(Protocol):
23
+ def __enter__(self) -> HTTPResponse: ...
24
+
25
+ def __exit__(
26
+ self,
27
+ exc_type: type[BaseException] | None,
28
+ exc_value: BaseException | None,
29
+ traceback: TracebackType | None,
30
+ ) -> None: ...
31
+
32
+ def read(self, size: int = -1) -> bytes: ...
33
+
34
+
35
+ class UrlOpen(Protocol):
36
+ def __call__(self, request: urllib.request.Request, *, timeout: float) -> HTTPResponse: ...
37
+
38
+
39
+ _DEFAULT_URLOPEN = cast(UrlOpen, urllib.request.urlopen)
40
+
41
+
42
+ class ClaimConflict(OutpostsAPIError):
43
+ """The session was claimed by another worker first."""
44
+
45
+
46
+ class _HTTPError(OutpostsAPIError):
47
+ def __init__(self, status_code: int, message: str) -> None:
48
+ self.status_code = status_code
49
+ super().__init__(message)
50
+
51
+
52
+ @dataclass(frozen=True, slots=True)
53
+ class Claim:
54
+ deadline: str | None
55
+
56
+
57
+ class SessionStatus(StrEnum):
58
+ """Session states understood by the Outposts worker protocol."""
59
+
60
+ NEW = "new"
61
+ PENDING = "pending"
62
+ CLAIMED = "claimed"
63
+ RUNNING = "running"
64
+ RESUMING = "resuming"
65
+ SUSPENDED = "suspended"
66
+ EXIT = "exit"
67
+ ERROR = "error"
68
+ TERMINATED = "terminated"
69
+
70
+
71
+ def _json_object(body: bytes, *, url: str) -> JsonObject:
72
+ if not body:
73
+ return {}
74
+ try:
75
+ parsed = json.loads(body)
76
+ except (UnicodeDecodeError, json.JSONDecodeError) as error:
77
+ raise OutpostsProtocolError(f"invalid JSON response from {url}") from error
78
+ if not isinstance(parsed, dict):
79
+ raise OutpostsProtocolError(
80
+ f"expected a JSON object from {url}, got {type(parsed).__name__}"
81
+ )
82
+ return cast(JsonObject, parsed)
83
+
84
+
85
+ def _items(response: Mapping[str, JsonValue]) -> Iterable[Mapping[str, JsonValue]]:
86
+ items = response.get("items")
87
+ if not isinstance(items, list):
88
+ raise OutpostsProtocolError("Outposts response is missing an items array")
89
+ for index, item in enumerate(items):
90
+ if not isinstance(item, dict):
91
+ raise OutpostsProtocolError(
92
+ f"expected items[{index}] to be an object, got {type(item).__name__}"
93
+ )
94
+ yield item
95
+
96
+
97
+ def _nested_string(mapping: Mapping[str, JsonValue], outer_key: str, inner_key: str) -> str | None:
98
+ outer = mapping.get(outer_key)
99
+ if not isinstance(outer, dict):
100
+ return None
101
+ value = outer.get(inner_key)
102
+ return value if isinstance(value, str) else None
103
+
104
+
105
+ def _required_nested_string(
106
+ mapping: Mapping[str, JsonValue],
107
+ outer_key: str,
108
+ inner_key: str,
109
+ *,
110
+ context: str,
111
+ ) -> str:
112
+ value = _nested_string(mapping, outer_key, inner_key)
113
+ if value is None:
114
+ raise OutpostsProtocolError(f"{context} is missing {outer_key}.{inner_key}")
115
+ return value
116
+
117
+
118
+ def _session_status(value: str) -> SessionStatus:
119
+ try:
120
+ return SessionStatus(value)
121
+ except ValueError as error:
122
+ raise OutpostsProtocolError(f"unknown Outposts session status: {value!r}") from error
123
+
124
+
125
+ class OutpostsClient:
126
+ """Small synchronous client used by the worker runtime."""
127
+
128
+ def __init__(
129
+ self,
130
+ base_url: str,
131
+ token: str,
132
+ *,
133
+ timeout: float,
134
+ urlopen: UrlOpen = _DEFAULT_URLOPEN,
135
+ ) -> None:
136
+ self.base_url = base_url.rstrip("/")
137
+ self.token = token
138
+ self.timeout = timeout
139
+ self._urlopen = urlopen
140
+
141
+ def _request(
142
+ self,
143
+ method: str,
144
+ path: str,
145
+ body: Mapping[str, JsonValue] | None = None,
146
+ ) -> JsonObject:
147
+ data = json.dumps(body).encode() if body is not None else None
148
+ url = f"{self.base_url}/{path.lstrip('/')}"
149
+ request = urllib.request.Request(url, data=data, method=method)
150
+ request.add_header("Accept", "application/json")
151
+ request.add_header("Authorization", f"Bearer {self.token}")
152
+ request.add_header("User-Agent", "modal-devin")
153
+ if data is not None:
154
+ request.add_header("Content-Type", "application/json")
155
+ try:
156
+ with self._urlopen(request, timeout=self.timeout) as response:
157
+ response_body = response.read(_MAX_RESPONSE_BYTES + 1)
158
+ if len(response_body) > _MAX_RESPONSE_BYTES:
159
+ raise OutpostsProtocolError(f"response from {url} exceeded 1 MiB")
160
+ return _json_object(response_body, url=url)
161
+ except urllib.error.HTTPError as error:
162
+ raise _HTTPError(error.code, f"request to {url} failed: {error}") from error
163
+ except (urllib.error.URLError, TimeoutError) as error:
164
+ raise OutpostsAPIError(f"request to {url} failed: {error}") from error
165
+
166
+ @staticmethod
167
+ def _session_path(session_id: str, action: str) -> str:
168
+ encoded = urllib.parse.quote(session_id, safe="")
169
+ return f"/opbeta/outposts/devins/{encoded}/{action}"
170
+
171
+ def pending_session_ids(self, pool_id: str) -> tuple[str, ...]:
172
+ query = urllib.parse.urlencode({"pool": pool_id, "phase": "pending"})
173
+ response = self._request("GET", f"/opbeta/outposts/devins?{query}")
174
+ session_ids: list[str] = []
175
+ for index, item in enumerate(_items(response)):
176
+ session_ids.append(
177
+ _required_nested_string(
178
+ item,
179
+ "metadata",
180
+ "session_id",
181
+ context=f"pending item {index}",
182
+ )
183
+ )
184
+ return tuple(session_ids)
185
+
186
+ def claim(self, session_id: str, acceptor_id: str) -> Claim:
187
+ try:
188
+ response = self._request(
189
+ "POST",
190
+ self._session_path(session_id, "claim"),
191
+ {"acceptor_id": acceptor_id},
192
+ )
193
+ except _HTTPError as error:
194
+ if error.status_code == 409:
195
+ raise ClaimConflict(session_id) from error
196
+ raise OutpostsAPIError(f"claim request for {session_id!r} failed: {error}") from error
197
+ return Claim(deadline=_nested_string(response, "status", "claim_deadline"))
198
+
199
+ def release(self, session_id: str, acceptor_id: str) -> None:
200
+ self._request(
201
+ "POST",
202
+ self._session_path(session_id, "release"),
203
+ {"acceptor_id": acceptor_id},
204
+ )
205
+
206
+ def session_status(self, session_id: str, acceptor_id: str) -> SessionStatus | None:
207
+ query = urllib.parse.urlencode({"phase": "claimed", "acceptor_id": acceptor_id})
208
+ response = self._request("GET", f"/opbeta/outposts/devins?{query}")
209
+ for index, item in enumerate(_items(response)):
210
+ item_session_id = _required_nested_string(
211
+ item,
212
+ "metadata",
213
+ "session_id",
214
+ context=f"claimed item {index}",
215
+ )
216
+ if item_session_id == session_id:
217
+ value = _required_nested_string(
218
+ item,
219
+ "status",
220
+ "session_status",
221
+ context=f"claimed session {session_id!r}",
222
+ )
223
+ return _session_status(value)
224
+ return None
modal_devin/_config.py ADDED
@@ -0,0 +1,192 @@
1
+ """Validated configuration and Modal resource naming."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import math
7
+ import os
8
+ import re
9
+ import urllib.parse
10
+ from dataclasses import dataclass, fields
11
+ from typing import Any, Self, cast
12
+
13
+ from modal_devin._exceptions import ConfigurationError
14
+
15
+ DEFAULT_API_URL = "https://api.beta.devinenterprise.com"
16
+ DEFAULT_SCHEDULER_INTERVAL_SECONDS = 30
17
+ DEFAULT_SESSION_TIMEOUT_SECONDS = 1800
18
+ DEFAULT_API_TIMEOUT_SECONDS = 30.0
19
+ DEFAULT_SNAPSHOT_TTL_SECONDS = 30 * 24 * 60 * 60
20
+ DEFAULT_STATUS_ATTEMPTS = 3
21
+ DEFAULT_STATUS_RETRY_DELAY_SECONDS = 1.0
22
+ DEFAULT_SANDBOX_READY_TIMEOUT_SECONDS = 120
23
+ DEFAULT_SIDECAR_READY_TIMEOUT_SECONDS = 60
24
+ DEFAULT_SNAPSHOT_TIMEOUT_SECONDS = 120
25
+
26
+ _TERMINATION_MARGIN_SECONDS = 30
27
+
28
+ _RESOURCE_COMPONENT_RE = re.compile(r"[^a-z0-9._-]+")
29
+ _ENV_PREFIX = "MODAL_DEVIN_"
30
+
31
+
32
+ def _resource_slug(value: str, *, max_length: int = 32) -> str:
33
+ """Return a readable, bounded Modal resource-name component."""
34
+ lowercase = value.strip().lower()
35
+ normalized = _RESOURCE_COMPONENT_RE.sub("-", lowercase).strip("-._")
36
+ if not normalized:
37
+ normalized = "worker"
38
+ if normalized == lowercase and len(normalized) <= max_length:
39
+ return normalized
40
+ digest = hashlib.sha256(value.encode()).hexdigest()[:8]
41
+ prefix = normalized[: max_length - len(digest) - 1].rstrip("-._") or "worker"
42
+ return f"{prefix}-{digest}"
43
+
44
+
45
+ def _validate_api_url(value: str) -> str:
46
+ parsed = urllib.parse.urlsplit(value)
47
+ if parsed.scheme not in {"http", "https"} or not parsed.netloc:
48
+ raise ConfigurationError("api_url must be an absolute http:// or https:// URL")
49
+ if parsed.username or parsed.password:
50
+ raise ConfigurationError("api_url must not contain credentials")
51
+ if parsed.query or parsed.fragment:
52
+ raise ConfigurationError("api_url must not contain a query string or fragment")
53
+ return value.rstrip("/")
54
+
55
+
56
+ @dataclass(frozen=True, slots=True)
57
+ class WorkerSettings:
58
+ """Operational settings for a :class:`modal_devin.Worker`.
59
+
60
+ Use :meth:`from_env` to read ``MODAL_DEVIN_*`` overrides. Keeping this separate
61
+ from the worker identity makes configuration explicit and straightforward to test.
62
+ """
63
+
64
+ scheduler_interval_seconds: int = DEFAULT_SCHEDULER_INTERVAL_SECONDS
65
+ session_timeout_seconds: int = DEFAULT_SESSION_TIMEOUT_SECONDS
66
+ api_timeout_seconds: float = DEFAULT_API_TIMEOUT_SECONDS
67
+ snapshot_ttl_seconds: int | None = DEFAULT_SNAPSHOT_TTL_SECONDS
68
+ status_attempts: int = DEFAULT_STATUS_ATTEMPTS
69
+ status_retry_delay_seconds: float = DEFAULT_STATUS_RETRY_DELAY_SECONDS
70
+ sandbox_ready_timeout_seconds: int = DEFAULT_SANDBOX_READY_TIMEOUT_SECONDS
71
+ sidecar_ready_timeout_seconds: int = DEFAULT_SIDECAR_READY_TIMEOUT_SECONDS
72
+ snapshot_timeout_seconds: int = DEFAULT_SNAPSHOT_TIMEOUT_SECONDS
73
+ log_level: str = "INFO"
74
+
75
+ def __post_init__(self) -> None:
76
+ positive = (
77
+ "scheduler_interval_seconds",
78
+ "session_timeout_seconds",
79
+ "api_timeout_seconds",
80
+ "status_attempts",
81
+ "sandbox_ready_timeout_seconds",
82
+ "sidecar_ready_timeout_seconds",
83
+ "snapshot_timeout_seconds",
84
+ )
85
+ for name in positive:
86
+ if getattr(self, name) <= 0:
87
+ raise ConfigurationError(f"{name} must be greater than zero")
88
+ if self.snapshot_ttl_seconds is not None and self.snapshot_ttl_seconds <= 0:
89
+ raise ConfigurationError("snapshot_ttl_seconds must be positive or None")
90
+ if self.status_retry_delay_seconds < 0:
91
+ raise ConfigurationError("status_retry_delay_seconds must not be negative")
92
+ valid_levels = {"CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG"}
93
+ if self.log_level.upper() not in valid_levels:
94
+ raise ConfigurationError(
95
+ "log_level must be one of CRITICAL, ERROR, WARNING, INFO, or DEBUG"
96
+ )
97
+
98
+ @classmethod
99
+ def from_env(cls) -> Self:
100
+ """Build settings from ``MODAL_DEVIN_*`` environment variables.
101
+
102
+ Set ``MODAL_DEVIN_SNAPSHOT_TTL_SECONDS=none`` to retain snapshots indefinitely.
103
+ """
104
+ values: dict[str, object] = {}
105
+ integer_fields = {
106
+ "scheduler_interval_seconds",
107
+ "session_timeout_seconds",
108
+ "status_attempts",
109
+ "sandbox_ready_timeout_seconds",
110
+ "sidecar_ready_timeout_seconds",
111
+ "snapshot_timeout_seconds",
112
+ }
113
+ float_fields = {"api_timeout_seconds", "status_retry_delay_seconds"}
114
+ for field in fields(cls):
115
+ raw = os.environ.get(_ENV_PREFIX + field.name.upper())
116
+ if raw is None:
117
+ continue
118
+ try:
119
+ if field.name in integer_fields:
120
+ values[field.name] = int(raw)
121
+ elif field.name in float_fields:
122
+ values[field.name] = float(raw)
123
+ elif field.name == "snapshot_ttl_seconds":
124
+ values[field.name] = None if raw.lower() in {"none", "null"} else int(raw)
125
+ else:
126
+ values[field.name] = raw
127
+ except ValueError as error:
128
+ env_name = _ENV_PREFIX + field.name.upper()
129
+ raise ConfigurationError(f"invalid value for {env_name}: {raw!r}") from error
130
+ return cls(**cast(dict[str, Any], values))
131
+
132
+
133
+ def _status_budget_seconds(settings: WorkerSettings) -> float:
134
+ request_budget = settings.status_attempts * settings.api_timeout_seconds
135
+ retry_delay_budget = settings.status_retry_delay_seconds * sum(
136
+ range(1, settings.status_attempts)
137
+ )
138
+ return request_budget + retry_delay_budget
139
+
140
+
141
+ def _sandbox_lifetime_seconds(settings: WorkerSettings) -> int:
142
+ """Maximum Sandbox lifetime including work, startup, and recovery."""
143
+ return math.ceil(
144
+ settings.session_timeout_seconds
145
+ + settings.sandbox_ready_timeout_seconds
146
+ + settings.sidecar_ready_timeout_seconds
147
+ + _status_budget_seconds(settings)
148
+ + settings.snapshot_timeout_seconds
149
+ + _TERMINATION_MARGIN_SECONDS
150
+ )
151
+
152
+
153
+ def _session_function_timeout_seconds(settings: WorkerSettings) -> int:
154
+ """Minimum outer Function timeout around one complete Sandbox lifecycle."""
155
+ claim_and_release_budget = 2 * settings.api_timeout_seconds
156
+ return math.ceil(_sandbox_lifetime_seconds(settings) + claim_and_release_budget)
157
+
158
+
159
+ @dataclass(frozen=True, slots=True)
160
+ class WorkerConfig:
161
+ """Identity and validated runtime configuration for one worker deployment."""
162
+
163
+ name: str
164
+ pool_id: str
165
+ api_url: str = DEFAULT_API_URL
166
+
167
+ def __post_init__(self) -> None:
168
+ if not self.name.strip():
169
+ raise ConfigurationError("name must not be empty")
170
+ if not self.pool_id.strip():
171
+ raise ConfigurationError("pool_id must not be empty")
172
+ object.__setattr__(self, "api_url", _validate_api_url(self.api_url))
173
+
174
+ @property
175
+ def resource_slug(self) -> str:
176
+ return _resource_slug(self.name)
177
+
178
+ @property
179
+ def acceptor_id(self) -> str:
180
+ return f"modal-{self.resource_slug}"
181
+
182
+ @property
183
+ def app_name(self) -> str:
184
+ return f"modal-devin-{self.resource_slug}"
185
+
186
+ @property
187
+ def snapshot_store_name(self) -> str:
188
+ return f"modal-devin-{self.resource_slug}-snapshots"
189
+
190
+ @property
191
+ def image_build_app_name(self) -> str:
192
+ return f"modal-devin-{self.resource_slug}-image-builds"
@@ -0,0 +1,36 @@
1
+ """Public exception hierarchy for :mod:`modal_devin`."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class ModalDevinError(Exception):
7
+ """Base class for errors raised by modal-devin."""
8
+
9
+
10
+ class ConfigurationError(ModalDevinError, ValueError):
11
+ """The worker configuration is invalid."""
12
+
13
+
14
+ class ModalCompatibilityError(ModalDevinError):
15
+ """The installed Modal SDK does not provide a required capability."""
16
+
17
+
18
+ class OutpostsAPIError(ModalDevinError):
19
+ """A Devin Outposts API request failed."""
20
+
21
+
22
+ class OutpostsProtocolError(OutpostsAPIError):
23
+ """The Devin Outposts API returned an unexpected response."""
24
+
25
+
26
+ class SessionStatusUnknownError(ModalDevinError):
27
+ """A session's final status could not be established safely."""
28
+
29
+
30
+ class WorkerExitedError(ModalDevinError):
31
+ """The Devin worker process exited unsuccessfully."""
32
+
33
+ def __init__(self, session_id: str, returncode: int) -> None:
34
+ self.session_id = session_id
35
+ self.returncode = returncode
36
+ super().__init__(f"Devin worker for {session_id!r} exited with status {returncode}")