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.
- modal_devin/__init__.py +33 -0
- modal_devin/__main__.py +5 -0
- modal_devin/_client.py +224 -0
- modal_devin/_config.py +192 -0
- modal_devin/_exceptions.py +36 -0
- modal_devin/_runtime.py +467 -0
- modal_devin/cli.py +484 -0
- modal_devin/images.py +159 -0
- modal_devin/py.typed +1 -0
- modal_devin/templates/pool.py.tmpl +59 -0
- modal_devin/worker.py +217 -0
- modal_devin-0.1.0.dist-info/METADATA +125 -0
- modal_devin-0.1.0.dist-info/RECORD +16 -0
- modal_devin-0.1.0.dist-info/WHEEL +4 -0
- modal_devin-0.1.0.dist-info/entry_points.txt +2 -0
- modal_devin-0.1.0.dist-info/licenses/LICENSE +21 -0
modal_devin/__init__.py
ADDED
|
@@ -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
|
+
]
|
modal_devin/__main__.py
ADDED
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}")
|