synth-ai 0.2.8.dev12__py3-none-any.whl → 0.2.9.dev0__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.
- synth_ai/api/train/__init__.py +5 -0
- synth_ai/api/train/builders.py +165 -0
- synth_ai/api/train/cli.py +450 -0
- synth_ai/api/train/config_finder.py +168 -0
- synth_ai/api/train/env_resolver.py +302 -0
- synth_ai/api/train/pollers.py +66 -0
- synth_ai/api/train/task_app.py +193 -0
- synth_ai/api/train/utils.py +232 -0
- synth_ai/cli/__init__.py +23 -0
- synth_ai/cli/rl_demo.py +18 -6
- synth_ai/cli/root.py +38 -6
- synth_ai/cli/task_apps.py +1107 -0
- synth_ai/demo_registry.py +258 -0
- synth_ai/demos/core/cli.py +147 -111
- synth_ai/demos/demo_task_apps/__init__.py +7 -1
- synth_ai/demos/demo_task_apps/math/config.toml +55 -110
- synth_ai/demos/demo_task_apps/math/modal_task_app.py +157 -21
- synth_ai/demos/demo_task_apps/math/task_app_entry.py +39 -0
- synth_ai/task/__init__.py +94 -1
- synth_ai/task/apps/__init__.py +88 -0
- synth_ai/task/apps/grpo_crafter.py +438 -0
- synth_ai/task/apps/math_single_step.py +852 -0
- synth_ai/task/auth.py +153 -0
- synth_ai/task/client.py +165 -0
- synth_ai/task/contracts.py +29 -14
- synth_ai/task/datasets.py +105 -0
- synth_ai/task/errors.py +49 -0
- synth_ai/task/json.py +77 -0
- synth_ai/task/proxy.py +258 -0
- synth_ai/task/rubrics.py +212 -0
- synth_ai/task/server.py +398 -0
- synth_ai/task/tracing_utils.py +79 -0
- synth_ai/task/vendors.py +61 -0
- synth_ai/tracing_v3/session_tracer.py +13 -5
- synth_ai/tracing_v3/storage/base.py +10 -12
- synth_ai/tracing_v3/turso/manager.py +20 -6
- {synth_ai-0.2.8.dev12.dist-info → synth_ai-0.2.9.dev0.dist-info}/METADATA +3 -2
- {synth_ai-0.2.8.dev12.dist-info → synth_ai-0.2.9.dev0.dist-info}/RECORD +42 -18
- {synth_ai-0.2.8.dev12.dist-info → synth_ai-0.2.9.dev0.dist-info}/WHEEL +0 -0
- {synth_ai-0.2.8.dev12.dist-info → synth_ai-0.2.9.dev0.dist-info}/entry_points.txt +0 -0
- {synth_ai-0.2.8.dev12.dist-info → synth_ai-0.2.9.dev0.dist-info}/licenses/LICENSE +0 -0
- {synth_ai-0.2.8.dev12.dist-info → synth_ai-0.2.9.dev0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,1107 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
import contextlib
|
|
5
|
+
import functools
|
|
6
|
+
import hashlib
|
|
7
|
+
import importlib
|
|
8
|
+
import importlib.util
|
|
9
|
+
import inspect
|
|
10
|
+
import os
|
|
11
|
+
import signal
|
|
12
|
+
import shutil
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
import tempfile
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Callable, Iterable, Sequence
|
|
19
|
+
|
|
20
|
+
import click
|
|
21
|
+
from synth_ai.task.apps import ModalDeploymentConfig, TaskAppConfig, TaskAppEntry, registry
|
|
22
|
+
from synth_ai.task.server import run_task_app
|
|
23
|
+
|
|
24
|
+
REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
25
|
+
|
|
26
|
+
DEFAULT_IGNORE_DIRS = {
|
|
27
|
+
".git",
|
|
28
|
+
"__pycache__",
|
|
29
|
+
"node_modules",
|
|
30
|
+
"venv",
|
|
31
|
+
".venv",
|
|
32
|
+
"build",
|
|
33
|
+
"dist",
|
|
34
|
+
".mypy_cache",
|
|
35
|
+
".pytest_cache",
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
DEFAULT_SEARCH_RELATIVE = (
|
|
39
|
+
Path("."),
|
|
40
|
+
Path("examples"),
|
|
41
|
+
Path("synth_ai"),
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class AppChoice:
|
|
47
|
+
app_id: str
|
|
48
|
+
label: str
|
|
49
|
+
path: Path
|
|
50
|
+
source: str
|
|
51
|
+
description: str | None = None
|
|
52
|
+
aliases: tuple[str, ...] = ()
|
|
53
|
+
entry: TaskAppEntry | None = None
|
|
54
|
+
entry_loader: Callable[[], TaskAppEntry] | None = None
|
|
55
|
+
modal_script: Path | None = None
|
|
56
|
+
lineno: int | None = None
|
|
57
|
+
|
|
58
|
+
def ensure_entry(self) -> TaskAppEntry:
|
|
59
|
+
if self.entry is not None:
|
|
60
|
+
return self.entry
|
|
61
|
+
if self.entry_loader is None:
|
|
62
|
+
raise click.ClickException(f"Unable to load task app '{self.app_id}' from {self.path}")
|
|
63
|
+
entry = self.entry_loader()
|
|
64
|
+
self.entry = entry
|
|
65
|
+
return entry
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _should_ignore_path(path: Path) -> bool:
|
|
69
|
+
return any(part in DEFAULT_IGNORE_DIRS for part in path.parts)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _candidate_search_roots() -> list[Path]:
|
|
73
|
+
roots: list[Path] = []
|
|
74
|
+
env_paths = os.environ.get("SYNTH_TASK_APP_SEARCH_PATH")
|
|
75
|
+
if env_paths:
|
|
76
|
+
for chunk in env_paths.split(os.pathsep):
|
|
77
|
+
if chunk:
|
|
78
|
+
roots.append(Path(chunk).expanduser())
|
|
79
|
+
|
|
80
|
+
cwd = Path.cwd().resolve()
|
|
81
|
+
roots.append(cwd)
|
|
82
|
+
|
|
83
|
+
for rel in DEFAULT_SEARCH_RELATIVE:
|
|
84
|
+
try:
|
|
85
|
+
candidate = (cwd / rel).resolve()
|
|
86
|
+
except Exception:
|
|
87
|
+
continue
|
|
88
|
+
roots.append(candidate)
|
|
89
|
+
if REPO_ROOT not in (None, candidate):
|
|
90
|
+
try:
|
|
91
|
+
repo_candidate = (REPO_ROOT / rel).resolve()
|
|
92
|
+
except Exception:
|
|
93
|
+
repo_candidate = None
|
|
94
|
+
if repo_candidate:
|
|
95
|
+
roots.append(repo_candidate)
|
|
96
|
+
|
|
97
|
+
roots.append(REPO_ROOT)
|
|
98
|
+
|
|
99
|
+
seen: set[Path] = set()
|
|
100
|
+
ordered: list[Path] = []
|
|
101
|
+
for root in roots:
|
|
102
|
+
try:
|
|
103
|
+
resolved = root.resolve()
|
|
104
|
+
except Exception:
|
|
105
|
+
continue
|
|
106
|
+
if resolved in seen or not resolved.exists():
|
|
107
|
+
continue
|
|
108
|
+
seen.add(resolved)
|
|
109
|
+
ordered.append(resolved)
|
|
110
|
+
return ordered
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class _TaskAppConfigVisitor(ast.NodeVisitor):
|
|
114
|
+
def __init__(self) -> None:
|
|
115
|
+
self.matches: list[tuple[str, int]] = []
|
|
116
|
+
|
|
117
|
+
def visit_Call(self, node: ast.Call) -> None: # noqa: D401
|
|
118
|
+
if _is_task_app_config_call(node):
|
|
119
|
+
app_id = _extract_app_id(node)
|
|
120
|
+
if app_id:
|
|
121
|
+
self.matches.append((app_id, getattr(node, "lineno", 0)))
|
|
122
|
+
self.generic_visit(node)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _is_task_app_config_call(node: ast.Call) -> bool:
|
|
126
|
+
func = node.func
|
|
127
|
+
if isinstance(func, ast.Name) and func.id == "TaskAppConfig":
|
|
128
|
+
return True
|
|
129
|
+
if isinstance(func, ast.Attribute) and func.attr == "TaskAppConfig":
|
|
130
|
+
return True
|
|
131
|
+
return False
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _extract_app_id(node: ast.Call) -> str | None:
|
|
135
|
+
for kw in node.keywords:
|
|
136
|
+
if kw.arg == "app_id" and isinstance(kw.value, ast.Constant) and isinstance(kw.value.value, str):
|
|
137
|
+
return kw.value.value
|
|
138
|
+
if node.args:
|
|
139
|
+
first = node.args[0]
|
|
140
|
+
if isinstance(first, ast.Constant) and isinstance(first.value, str):
|
|
141
|
+
return first.value
|
|
142
|
+
return None
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class _ModalAppVisitor(ast.NodeVisitor):
|
|
146
|
+
def __init__(self) -> None:
|
|
147
|
+
self.app_aliases: set[str] = set()
|
|
148
|
+
self.modal_aliases: set[str] = set()
|
|
149
|
+
self.matches: list[tuple[str, int]] = []
|
|
150
|
+
|
|
151
|
+
def visit_ImportFrom(self, node: ast.ImportFrom) -> None: # noqa: D401
|
|
152
|
+
if node.module == "modal":
|
|
153
|
+
for alias in node.names:
|
|
154
|
+
if alias.name == "App":
|
|
155
|
+
self.app_aliases.add(alias.asname or alias.name)
|
|
156
|
+
self.generic_visit(node)
|
|
157
|
+
|
|
158
|
+
def visit_Import(self, node: ast.Import) -> None: # noqa: D401
|
|
159
|
+
for alias in node.names:
|
|
160
|
+
if alias.name == "modal":
|
|
161
|
+
self.modal_aliases.add(alias.asname or alias.name)
|
|
162
|
+
self.generic_visit(node)
|
|
163
|
+
|
|
164
|
+
def visit_Call(self, node: ast.Call) -> None: # noqa: D401
|
|
165
|
+
func = node.func
|
|
166
|
+
if isinstance(func, ast.Name) and func.id in self.app_aliases:
|
|
167
|
+
name = _extract_modal_app_name(node)
|
|
168
|
+
if name:
|
|
169
|
+
self.matches.append((name, getattr(node, "lineno", 0)))
|
|
170
|
+
elif isinstance(func, ast.Attribute):
|
|
171
|
+
if isinstance(func.value, ast.Name) and func.value.id in self.modal_aliases and func.attr == "App":
|
|
172
|
+
name = _extract_modal_app_name(node)
|
|
173
|
+
if name:
|
|
174
|
+
self.matches.append((name, getattr(node, "lineno", 0)))
|
|
175
|
+
self.generic_visit(node)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _extract_modal_app_name(node: ast.Call) -> str | None:
|
|
179
|
+
for kw in node.keywords:
|
|
180
|
+
if kw.arg in {"name", "app_name"} and isinstance(kw.value, ast.Constant) and isinstance(kw.value.value, str):
|
|
181
|
+
return kw.value.value
|
|
182
|
+
if node.args:
|
|
183
|
+
first = node.args[0]
|
|
184
|
+
if isinstance(first, ast.Constant) and isinstance(first.value, str):
|
|
185
|
+
return first.value
|
|
186
|
+
return None
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@functools.lru_cache(maxsize=1)
|
|
190
|
+
def _collect_task_app_choices() -> list[AppChoice]:
|
|
191
|
+
choices: list[AppChoice] = []
|
|
192
|
+
with contextlib.suppress(Exception):
|
|
193
|
+
import synth_ai.demos.demo_task_apps # noqa: F401
|
|
194
|
+
choices.extend(_collect_registered_choices())
|
|
195
|
+
choices.extend(_collect_scanned_task_configs())
|
|
196
|
+
choices.extend(_collect_modal_scripts())
|
|
197
|
+
|
|
198
|
+
unique: dict[tuple[str, Path], AppChoice] = {}
|
|
199
|
+
ordered: list[AppChoice] = []
|
|
200
|
+
for choice in choices:
|
|
201
|
+
key = (choice.app_id, choice.path.resolve())
|
|
202
|
+
if key in unique:
|
|
203
|
+
existing = unique[key]
|
|
204
|
+
if existing.source == "registered" and choice.source != "registered":
|
|
205
|
+
continue
|
|
206
|
+
if choice.source == "registered" and existing.source != "registered":
|
|
207
|
+
unique[key] = choice
|
|
208
|
+
idx = ordered.index(existing)
|
|
209
|
+
ordered[idx] = choice
|
|
210
|
+
continue
|
|
211
|
+
unique[key] = choice
|
|
212
|
+
ordered.append(choice)
|
|
213
|
+
return ordered
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _collect_registered_choices() -> list[AppChoice]:
|
|
217
|
+
result: list[AppChoice] = []
|
|
218
|
+
for entry in registry.list():
|
|
219
|
+
module_name = entry.config_factory.__module__
|
|
220
|
+
module = sys.modules.get(module_name)
|
|
221
|
+
if module is None:
|
|
222
|
+
module = importlib.import_module(module_name)
|
|
223
|
+
module_file = getattr(module, "__file__", None)
|
|
224
|
+
path = Path(module_file).resolve() if module_file else REPO_ROOT
|
|
225
|
+
result.append(
|
|
226
|
+
AppChoice(
|
|
227
|
+
app_id=entry.app_id,
|
|
228
|
+
label=entry.app_id,
|
|
229
|
+
path=path,
|
|
230
|
+
source="registered",
|
|
231
|
+
description=entry.description,
|
|
232
|
+
aliases=tuple(entry.aliases),
|
|
233
|
+
entry=entry,
|
|
234
|
+
)
|
|
235
|
+
)
|
|
236
|
+
return result
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _collect_scanned_task_configs() -> list[AppChoice]:
|
|
240
|
+
results: list[AppChoice] = []
|
|
241
|
+
seen: set[tuple[str, Path]] = set()
|
|
242
|
+
for root in _candidate_search_roots():
|
|
243
|
+
if not root.exists() or not root.is_dir():
|
|
244
|
+
continue
|
|
245
|
+
for path in root.rglob("*.py"):
|
|
246
|
+
if not path.is_file():
|
|
247
|
+
continue
|
|
248
|
+
if _should_ignore_path(path):
|
|
249
|
+
continue
|
|
250
|
+
try:
|
|
251
|
+
source = path.read_text(encoding="utf-8")
|
|
252
|
+
except Exception:
|
|
253
|
+
continue
|
|
254
|
+
try:
|
|
255
|
+
tree = ast.parse(source, filename=str(path))
|
|
256
|
+
except SyntaxError:
|
|
257
|
+
continue
|
|
258
|
+
visitor = _TaskAppConfigVisitor()
|
|
259
|
+
visitor.visit(tree)
|
|
260
|
+
for app_id, lineno in visitor.matches:
|
|
261
|
+
key = (app_id, path.resolve())
|
|
262
|
+
if key in seen:
|
|
263
|
+
continue
|
|
264
|
+
seen.add(key)
|
|
265
|
+
results.append(
|
|
266
|
+
AppChoice(
|
|
267
|
+
app_id=app_id,
|
|
268
|
+
label=app_id,
|
|
269
|
+
path=path.resolve(),
|
|
270
|
+
source="discovered",
|
|
271
|
+
description=f"TaskAppConfig in {path.name} (line {lineno})",
|
|
272
|
+
entry_loader=lambda p=path.resolve(), a=app_id: _load_entry_from_path(p, a),
|
|
273
|
+
lineno=lineno,
|
|
274
|
+
)
|
|
275
|
+
)
|
|
276
|
+
return results
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _collect_modal_scripts() -> list[AppChoice]:
|
|
280
|
+
results: list[AppChoice] = []
|
|
281
|
+
seen: set[tuple[str, Path]] = set()
|
|
282
|
+
for root in _candidate_search_roots():
|
|
283
|
+
if not root.exists() or not root.is_dir():
|
|
284
|
+
continue
|
|
285
|
+
for path in root.rglob("*.py"):
|
|
286
|
+
if not path.is_file():
|
|
287
|
+
continue
|
|
288
|
+
if _should_ignore_path(path):
|
|
289
|
+
continue
|
|
290
|
+
try:
|
|
291
|
+
source = path.read_text(encoding="utf-8")
|
|
292
|
+
except Exception:
|
|
293
|
+
continue
|
|
294
|
+
try:
|
|
295
|
+
tree = ast.parse(source, filename=str(path))
|
|
296
|
+
except SyntaxError:
|
|
297
|
+
continue
|
|
298
|
+
visitor = _ModalAppVisitor()
|
|
299
|
+
visitor.visit(tree)
|
|
300
|
+
for app_name, lineno in visitor.matches:
|
|
301
|
+
key = (app_name, path.resolve())
|
|
302
|
+
if key in seen:
|
|
303
|
+
continue
|
|
304
|
+
seen.add(key)
|
|
305
|
+
results.append(
|
|
306
|
+
AppChoice(
|
|
307
|
+
app_id=app_name,
|
|
308
|
+
label=app_name,
|
|
309
|
+
path=path.resolve(),
|
|
310
|
+
source="modal-script",
|
|
311
|
+
description=f"Modal App '{app_name}' in {path.name} (line {lineno})",
|
|
312
|
+
modal_script=path.resolve(),
|
|
313
|
+
lineno=lineno,
|
|
314
|
+
)
|
|
315
|
+
)
|
|
316
|
+
return results
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _choice_matches_identifier(choice: AppChoice, identifier: str) -> bool:
|
|
320
|
+
ident = identifier.strip()
|
|
321
|
+
if not ident:
|
|
322
|
+
return False
|
|
323
|
+
if ident == choice.app_id or ident == choice.label:
|
|
324
|
+
return True
|
|
325
|
+
if ident in choice.aliases:
|
|
326
|
+
return True
|
|
327
|
+
return False
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _choice_has_modal_support(choice: AppChoice) -> bool:
|
|
331
|
+
if choice.modal_script:
|
|
332
|
+
return True
|
|
333
|
+
try:
|
|
334
|
+
entry = choice.ensure_entry()
|
|
335
|
+
except click.ClickException:
|
|
336
|
+
return False
|
|
337
|
+
return entry.modal is not None
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _choice_has_local_support(choice: AppChoice) -> bool:
|
|
341
|
+
if choice.modal_script:
|
|
342
|
+
return False
|
|
343
|
+
try:
|
|
344
|
+
choice.ensure_entry()
|
|
345
|
+
except click.ClickException:
|
|
346
|
+
return False
|
|
347
|
+
return True
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _format_choice(choice: AppChoice, index: int | None = None) -> str:
|
|
351
|
+
prefix = f"[{index}] " if index is not None else ""
|
|
352
|
+
rel_path: str
|
|
353
|
+
try:
|
|
354
|
+
rel_path = str(choice.path.relative_to(REPO_ROOT))
|
|
355
|
+
except Exception:
|
|
356
|
+
rel_path = str(choice.path)
|
|
357
|
+
details = choice.description or f"Located at {rel_path}"
|
|
358
|
+
return f"{prefix}{choice.app_id} ({choice.source}) – {details}"
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _prompt_user_for_choice(choices: list[AppChoice]) -> AppChoice:
|
|
362
|
+
click.echo("Select a task app:")
|
|
363
|
+
for idx, choice in enumerate(choices, start=1):
|
|
364
|
+
click.echo(_format_choice(choice, idx))
|
|
365
|
+
response = click.prompt("Enter choice", default="1", type=str).strip() or "1"
|
|
366
|
+
if not response.isdigit():
|
|
367
|
+
raise click.ClickException("Selection must be a number")
|
|
368
|
+
index = int(response)
|
|
369
|
+
if not 1 <= index <= len(choices):
|
|
370
|
+
raise click.ClickException("Selection out of range")
|
|
371
|
+
return choices[index - 1]
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _select_app_choice(app_id: str | None, purpose: str) -> AppChoice:
|
|
375
|
+
choices = _collect_task_app_choices()
|
|
376
|
+
if purpose == "serve":
|
|
377
|
+
filtered = [c for c in choices if not c.modal_script]
|
|
378
|
+
elif purpose in {"deploy", "modal-serve"}:
|
|
379
|
+
filtered = []
|
|
380
|
+
for choice in choices:
|
|
381
|
+
if choice.modal_script or _choice_has_modal_support(choice):
|
|
382
|
+
filtered.append(choice)
|
|
383
|
+
else:
|
|
384
|
+
filtered = choices
|
|
385
|
+
|
|
386
|
+
if not filtered:
|
|
387
|
+
raise click.ClickException("No task apps discovered for this command.")
|
|
388
|
+
|
|
389
|
+
if app_id:
|
|
390
|
+
matches = [c for c in filtered if _choice_matches_identifier(c, app_id)]
|
|
391
|
+
if not matches:
|
|
392
|
+
available = ", ".join(sorted({c.app_id for c in filtered}))
|
|
393
|
+
raise click.ClickException(f"Task app '{app_id}' not found. Available: {available}")
|
|
394
|
+
if len(matches) == 1:
|
|
395
|
+
return matches[0]
|
|
396
|
+
# Prefer entries with modal support when required
|
|
397
|
+
if purpose in {"deploy", "modal-serve"}:
|
|
398
|
+
modal_matches = [c for c in matches if _choice_has_modal_support(c)]
|
|
399
|
+
if len(modal_matches) == 1:
|
|
400
|
+
return modal_matches[0]
|
|
401
|
+
if modal_matches:
|
|
402
|
+
matches = modal_matches
|
|
403
|
+
return _prompt_user_for_choice(matches)
|
|
404
|
+
|
|
405
|
+
if len(filtered) == 1:
|
|
406
|
+
choice = filtered[0]
|
|
407
|
+
click.echo(_format_choice(choice))
|
|
408
|
+
return choice
|
|
409
|
+
|
|
410
|
+
return _prompt_user_for_choice(filtered)
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _load_entry_from_path(path: Path, app_id: str) -> TaskAppEntry:
|
|
414
|
+
resolved = path.resolve()
|
|
415
|
+
module_name = f"_synth_task_app_{hashlib.md5(str(resolved).encode(), usedforsecurity=False).hexdigest()}"
|
|
416
|
+
spec = importlib.util.spec_from_file_location(module_name, str(resolved))
|
|
417
|
+
if spec is None or spec.loader is None:
|
|
418
|
+
raise click.ClickException(f"Unable to load Python module from {resolved}")
|
|
419
|
+
module = importlib.util.module_from_spec(spec)
|
|
420
|
+
sys.modules[module_name] = module
|
|
421
|
+
try:
|
|
422
|
+
spec.loader.exec_module(module)
|
|
423
|
+
except Exception as exc:
|
|
424
|
+
raise click.ClickException(f"Failed to import {resolved}: {exc}") from exc
|
|
425
|
+
|
|
426
|
+
config_obj: TaskAppConfig | None = None
|
|
427
|
+
factory_callable: Callable[[], TaskAppConfig] | None = None
|
|
428
|
+
|
|
429
|
+
for attr_name in dir(module):
|
|
430
|
+
try:
|
|
431
|
+
attr = getattr(module, attr_name)
|
|
432
|
+
except Exception:
|
|
433
|
+
continue
|
|
434
|
+
if isinstance(attr, TaskAppConfig) and attr.app_id == app_id:
|
|
435
|
+
config_obj = attr
|
|
436
|
+
factory_callable = lambda cfg=attr: cfg
|
|
437
|
+
break
|
|
438
|
+
|
|
439
|
+
if factory_callable is None:
|
|
440
|
+
for attr_name in dir(module):
|
|
441
|
+
if attr_name.startswith("_"):
|
|
442
|
+
continue
|
|
443
|
+
try:
|
|
444
|
+
attr = getattr(module, attr_name)
|
|
445
|
+
except Exception:
|
|
446
|
+
continue
|
|
447
|
+
if not callable(attr):
|
|
448
|
+
continue
|
|
449
|
+
try:
|
|
450
|
+
sig = inspect.signature(attr)
|
|
451
|
+
except (TypeError, ValueError):
|
|
452
|
+
continue
|
|
453
|
+
has_required = False
|
|
454
|
+
for param in sig.parameters.values():
|
|
455
|
+
if param.kind in (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD) and param.default is inspect._empty:
|
|
456
|
+
has_required = True
|
|
457
|
+
break
|
|
458
|
+
if has_required:
|
|
459
|
+
continue
|
|
460
|
+
try:
|
|
461
|
+
result = attr()
|
|
462
|
+
except Exception:
|
|
463
|
+
continue
|
|
464
|
+
if isinstance(result, TaskAppConfig) and result.app_id == app_id:
|
|
465
|
+
def _factory() -> TaskAppConfig:
|
|
466
|
+
return attr() # type: ignore[call-arg]
|
|
467
|
+
factory_callable = _factory
|
|
468
|
+
config_obj = result
|
|
469
|
+
break
|
|
470
|
+
|
|
471
|
+
if factory_callable is None or config_obj is None:
|
|
472
|
+
raise click.ClickException(
|
|
473
|
+
f"Could not locate TaskAppConfig for '{app_id}' in {resolved}."
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
modal_cfg: ModalDeploymentConfig | None = None
|
|
477
|
+
for attr_name in dir(module):
|
|
478
|
+
try:
|
|
479
|
+
attr = getattr(module, attr_name)
|
|
480
|
+
except Exception:
|
|
481
|
+
continue
|
|
482
|
+
if isinstance(attr, ModalDeploymentConfig):
|
|
483
|
+
modal_cfg = attr
|
|
484
|
+
break
|
|
485
|
+
|
|
486
|
+
description = inspect.getdoc(module) or f"Discovered task app in {resolved.name}"
|
|
487
|
+
env_files: Iterable[str] = getattr(module, "ENV_FILES", ()) # type: ignore[arg-type]
|
|
488
|
+
|
|
489
|
+
entry = TaskAppEntry(
|
|
490
|
+
app_id=app_id,
|
|
491
|
+
description=description,
|
|
492
|
+
config_factory=factory_callable,
|
|
493
|
+
aliases=(),
|
|
494
|
+
env_files=tuple(str(Path(p)) for p in env_files if p),
|
|
495
|
+
modal=modal_cfg,
|
|
496
|
+
)
|
|
497
|
+
return entry
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def _resolve_env_paths_for_script(script_path: Path, explicit: Sequence[str]) -> list[Path]:
|
|
501
|
+
if explicit:
|
|
502
|
+
resolved: list[Path] = []
|
|
503
|
+
for candidate in explicit:
|
|
504
|
+
p = Path(candidate).expanduser()
|
|
505
|
+
if not p.exists():
|
|
506
|
+
raise click.ClickException(f"Env file not found: {p}")
|
|
507
|
+
resolved.append(p)
|
|
508
|
+
return resolved
|
|
509
|
+
|
|
510
|
+
script_dir = script_path.parent.resolve()
|
|
511
|
+
fallback_order = [
|
|
512
|
+
script_dir / ".env",
|
|
513
|
+
REPO_ROOT / "examples" / "rl" / ".env",
|
|
514
|
+
REPO_ROOT / "examples" / "warming_up_to_rl" / ".env",
|
|
515
|
+
REPO_ROOT / ".env",
|
|
516
|
+
]
|
|
517
|
+
resolved = [p for p in fallback_order if p.exists()]
|
|
518
|
+
if resolved:
|
|
519
|
+
return resolved
|
|
520
|
+
created = _interactive_create_env(script_dir)
|
|
521
|
+
if created is None:
|
|
522
|
+
raise click.ClickException("Env file required (--env-file) for this task app")
|
|
523
|
+
return [created]
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def _run_modal_script(
|
|
527
|
+
script_path: Path,
|
|
528
|
+
modal_cli: str,
|
|
529
|
+
command: str,
|
|
530
|
+
env_paths: Sequence[Path],
|
|
531
|
+
*,
|
|
532
|
+
modal_name: str | None = None,
|
|
533
|
+
dry_run: bool = False,
|
|
534
|
+
) -> None:
|
|
535
|
+
modal_path = shutil.which(modal_cli)
|
|
536
|
+
if modal_path is None:
|
|
537
|
+
raise click.ClickException(f"Modal CLI not found (looked for '{modal_cli}')")
|
|
538
|
+
|
|
539
|
+
env_paths_list = [Path(p).resolve() for p in env_paths]
|
|
540
|
+
path_strings = [str(p) for p in env_paths_list]
|
|
541
|
+
_load_env_files_into_process(path_strings)
|
|
542
|
+
_ensure_env_values(env_paths_list, script_path.parent)
|
|
543
|
+
_load_env_values(env_paths_list)
|
|
544
|
+
|
|
545
|
+
cmd = [modal_path, command, str(script_path)]
|
|
546
|
+
if modal_name:
|
|
547
|
+
cmd.extend(["--name", modal_name])
|
|
548
|
+
if dry_run:
|
|
549
|
+
click.echo("Dry run: " + " ".join(cmd))
|
|
550
|
+
return
|
|
551
|
+
try:
|
|
552
|
+
subprocess.run(cmd, check=True)
|
|
553
|
+
except subprocess.CalledProcessError as exc:
|
|
554
|
+
raise click.ClickException(f"modal {command} failed with exit code {exc.returncode}") from exc
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def _preflight_env_key() -> None:
|
|
558
|
+
try:
|
|
559
|
+
raw_backend = os.environ.get("BACKEND_BASE_URL") or os.environ.get("SYNTH_BASE_URL") or "http://localhost:8000/api"
|
|
560
|
+
backend_base = raw_backend.rstrip('/')
|
|
561
|
+
if not backend_base.endswith('/api'):
|
|
562
|
+
backend_base = backend_base + '/api'
|
|
563
|
+
synth_key = os.environ.get("SYNTH_API_KEY") or ""
|
|
564
|
+
env_api_key = (
|
|
565
|
+
os.environ.get("ENVIRONMENT_API_KEY")
|
|
566
|
+
or os.environ.get("dev_environment_api_key")
|
|
567
|
+
or os.environ.get("DEV_ENVIRONMENT_API_KEY")
|
|
568
|
+
or ""
|
|
569
|
+
)
|
|
570
|
+
if synth_key and env_api_key:
|
|
571
|
+
import base64
|
|
572
|
+
import httpx
|
|
573
|
+
|
|
574
|
+
click.echo(f"[preflight] backend={backend_base}")
|
|
575
|
+
with httpx.Client(timeout=15.0, headers={"Authorization": f"Bearer {synth_key}"}) as c:
|
|
576
|
+
click.echo("[preflight] fetching public key…")
|
|
577
|
+
rpk = c.get(f"{backend_base.rstrip('/')}/v1/crypto/public-key")
|
|
578
|
+
pk = (rpk.json() or {}).get("public_key") if rpk.status_code == 200 else None
|
|
579
|
+
if pk:
|
|
580
|
+
try:
|
|
581
|
+
from nacl.public import PublicKey, SealedBox
|
|
582
|
+
|
|
583
|
+
pub = PublicKey(base64.b64decode(pk, validate=True))
|
|
584
|
+
sb = SealedBox(pub)
|
|
585
|
+
ct_b64 = base64.b64encode(sb.encrypt(env_api_key.encode('utf-8'))).decode()
|
|
586
|
+
payload = {"name": "ENVIRONMENT_API_KEY", "ciphertext_b64": ct_b64}
|
|
587
|
+
with httpx.Client(timeout=15.0, headers={"Authorization": f"Bearer {synth_key}", "Content-Type": "application/json"}) as c:
|
|
588
|
+
click.echo("[preflight] upserting env key…")
|
|
589
|
+
up = c.post(f"{backend_base.rstrip('/')}/v1/env-keys", json=payload)
|
|
590
|
+
click.echo(f"[preflight] upsert status={up.status_code}")
|
|
591
|
+
click.echo("[preflight] verifying env key presence…")
|
|
592
|
+
ver = c.get(f"{backend_base.rstrip('/')}/v1/env-keys/verify")
|
|
593
|
+
if ver.status_code == 200 and (ver.json() or {}).get("present"):
|
|
594
|
+
click.echo("✅ ENVIRONMENT_API_KEY upserted and verified in backend")
|
|
595
|
+
else:
|
|
596
|
+
click.echo("[WARN] ENVIRONMENT_API_KEY verification failed; proceeding anyway")
|
|
597
|
+
except Exception:
|
|
598
|
+
click.echo("[WARN] Failed to encrypt/upload ENVIRONMENT_API_KEY; proceeding anyway")
|
|
599
|
+
except Exception:
|
|
600
|
+
click.echo("[WARN] Backend preflight for ENVIRONMENT_API_KEY failed; proceeding anyway")
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
def _run_modal_with_entry(
|
|
604
|
+
entry: TaskAppEntry,
|
|
605
|
+
modal_cfg: ModalDeploymentConfig,
|
|
606
|
+
modal_cli: str,
|
|
607
|
+
modal_name: str | None,
|
|
608
|
+
env_paths: list[Path],
|
|
609
|
+
command: str,
|
|
610
|
+
*,
|
|
611
|
+
dry_run: bool = False,
|
|
612
|
+
) -> None:
|
|
613
|
+
modal_path = shutil.which(modal_cli)
|
|
614
|
+
if modal_path is None:
|
|
615
|
+
raise click.ClickException(f"Modal CLI not found (looked for '{modal_cli}')")
|
|
616
|
+
|
|
617
|
+
env_paths_list = [Path(p).resolve() for p in env_paths]
|
|
618
|
+
dotenv_paths = [str(p) for p in env_paths_list]
|
|
619
|
+
_load_env_files_into_process(dotenv_paths)
|
|
620
|
+
fallback_dir = env_paths_list[0].parent if env_paths_list else Path.cwd()
|
|
621
|
+
_ensure_env_values(env_paths_list, fallback_dir)
|
|
622
|
+
_load_env_values(env_paths_list)
|
|
623
|
+
_preflight_env_key()
|
|
624
|
+
|
|
625
|
+
script_path = _write_modal_entrypoint(
|
|
626
|
+
entry,
|
|
627
|
+
modal_cfg,
|
|
628
|
+
modal_name,
|
|
629
|
+
dotenv_paths=dotenv_paths,
|
|
630
|
+
)
|
|
631
|
+
cmd = [modal_path, command, str(script_path)]
|
|
632
|
+
|
|
633
|
+
if dry_run:
|
|
634
|
+
click.echo("Dry run: " + " ".join(cmd))
|
|
635
|
+
script_path.unlink(missing_ok=True)
|
|
636
|
+
return
|
|
637
|
+
|
|
638
|
+
try:
|
|
639
|
+
subprocess.run(cmd, check=True)
|
|
640
|
+
except subprocess.CalledProcessError as exc:
|
|
641
|
+
raise click.ClickException(f"modal {command} failed with exit code {exc.returncode}") from exc
|
|
642
|
+
finally:
|
|
643
|
+
script_path.unlink(missing_ok=True)
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
def _load_env_values(paths: list[Path], *, allow_empty: bool = False) -> dict[str, str]:
|
|
649
|
+
values: dict[str, str] = {}
|
|
650
|
+
for p in paths:
|
|
651
|
+
try:
|
|
652
|
+
content = p.read_text(encoding="utf-8")
|
|
653
|
+
except FileNotFoundError:
|
|
654
|
+
continue
|
|
655
|
+
for line in content.splitlines():
|
|
656
|
+
if not line or line.lstrip().startswith('#') or '=' not in line:
|
|
657
|
+
continue
|
|
658
|
+
key, value = line.split('=', 1)
|
|
659
|
+
if key and key not in values:
|
|
660
|
+
values[key.strip()] = value.strip()
|
|
661
|
+
if not allow_empty and not values:
|
|
662
|
+
raise click.ClickException("No environment values found")
|
|
663
|
+
os.environ.update({k: v for k, v in values.items() if k and v})
|
|
664
|
+
return values
|
|
665
|
+
def _interactive_create_env(target_dir: Path) -> Path | None:
|
|
666
|
+
env_path = (target_dir / ".env").resolve()
|
|
667
|
+
if env_path.exists():
|
|
668
|
+
existing = _parse_env_file(env_path)
|
|
669
|
+
env_api = (existing.get("ENVIRONMENT_API_KEY") or "").strip()
|
|
670
|
+
if env_api:
|
|
671
|
+
return env_path
|
|
672
|
+
click.echo(f"Existing {env_path} is missing ENVIRONMENT_API_KEY. Let's update it.")
|
|
673
|
+
return _interactive_fill_env(env_path)
|
|
674
|
+
|
|
675
|
+
click.echo("No .env found for this task app. Let's create one.")
|
|
676
|
+
return _interactive_fill_env(env_path)
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
def _parse_env_file(path: Path) -> dict[str, str]:
|
|
680
|
+
data: dict[str, str] = {}
|
|
681
|
+
try:
|
|
682
|
+
for line in path.read_text(encoding="utf-8").splitlines():
|
|
683
|
+
if not line or line.lstrip().startswith('#') or '=' not in line:
|
|
684
|
+
continue
|
|
685
|
+
key, value = line.split('=', 1)
|
|
686
|
+
data[key.strip()] = value.strip()
|
|
687
|
+
except FileNotFoundError:
|
|
688
|
+
pass
|
|
689
|
+
return data
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
def _interactive_fill_env(env_path: Path) -> Path | None:
|
|
693
|
+
existing = _parse_env_file(env_path) if env_path.exists() else {}
|
|
694
|
+
|
|
695
|
+
def _prompt(label: str, *, default: str = "", required: bool) -> str | None:
|
|
696
|
+
while True:
|
|
697
|
+
try:
|
|
698
|
+
value = click.prompt(label, default=default, show_default=bool(default) or not required).strip()
|
|
699
|
+
except (click.exceptions.Abort, EOFError, KeyboardInterrupt):
|
|
700
|
+
click.echo("Aborted env creation.")
|
|
701
|
+
return None
|
|
702
|
+
if value or not required:
|
|
703
|
+
return value
|
|
704
|
+
click.echo("This field is required.")
|
|
705
|
+
|
|
706
|
+
env_default = existing.get("ENVIRONMENT_API_KEY", "").strip()
|
|
707
|
+
env_api_key = _prompt("ENVIRONMENT_API_KEY", default=env_default, required=True)
|
|
708
|
+
if env_api_key is None:
|
|
709
|
+
return None
|
|
710
|
+
synth_default = existing.get("SYNTH_API_KEY", "").strip()
|
|
711
|
+
openai_default = existing.get("OPENAI_API_KEY", "").strip()
|
|
712
|
+
synth_key = _prompt("SYNTH_API_KEY (optional)", default=synth_default, required=False) or ""
|
|
713
|
+
openai_key = _prompt("OPENAI_API_KEY (optional)", default=openai_default, required=False) or ""
|
|
714
|
+
|
|
715
|
+
lines = [
|
|
716
|
+
f"ENVIRONMENT_API_KEY={env_api_key}",
|
|
717
|
+
f"SYNTH_API_KEY={synth_key}",
|
|
718
|
+
f"OPENAI_API_KEY={openai_key}",
|
|
719
|
+
]
|
|
720
|
+
env_path.parent.mkdir(parents=True, exist_ok=True)
|
|
721
|
+
env_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
722
|
+
click.echo(f"Wrote credentials to {env_path}")
|
|
723
|
+
return env_path
|
|
724
|
+
|
|
725
|
+
|
|
726
|
+
def _ensure_env_values(env_paths: list[Path], fallback_dir: Path) -> None:
|
|
727
|
+
if (os.environ.get("ENVIRONMENT_API_KEY") or "").strip():
|
|
728
|
+
return
|
|
729
|
+
target = env_paths[0] if env_paths else (fallback_dir / ".env").resolve()
|
|
730
|
+
result = _interactive_fill_env(target)
|
|
731
|
+
if result is None:
|
|
732
|
+
raise click.ClickException("ENVIRONMENT_API_KEY required to continue")
|
|
733
|
+
# After generating .env, load it and override any previously-empty values
|
|
734
|
+
_load_env_values([result])
|
|
735
|
+
if not (os.environ.get("ENVIRONMENT_API_KEY") or "").strip():
|
|
736
|
+
raise click.ClickException("Failed to load ENVIRONMENT_API_KEY from generated .env")
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
def _deploy_entry(
|
|
740
|
+
entry: TaskAppEntry,
|
|
741
|
+
modal_name: str | None,
|
|
742
|
+
dry_run: bool,
|
|
743
|
+
modal_cli: str,
|
|
744
|
+
env_file: Sequence[str],
|
|
745
|
+
) -> None:
|
|
746
|
+
modal_cfg = entry.modal
|
|
747
|
+
if modal_cfg is None:
|
|
748
|
+
raise click.ClickException(f"Task app '{entry.app_id}' does not define Modal deployment settings")
|
|
749
|
+
|
|
750
|
+
env_paths = _determine_env_files(entry, env_file)
|
|
751
|
+
click.echo('Using env file(s): ' + ', '.join(str(p) for p in env_paths))
|
|
752
|
+
_run_modal_with_entry(entry, modal_cfg, modal_cli, modal_name, env_paths, command="deploy", dry_run=dry_run)
|
|
753
|
+
|
|
754
|
+
|
|
755
|
+
def _modal_serve_entry(
|
|
756
|
+
entry: TaskAppEntry,
|
|
757
|
+
modal_name: str | None,
|
|
758
|
+
modal_cli: str,
|
|
759
|
+
env_file: Sequence[str],
|
|
760
|
+
) -> None:
|
|
761
|
+
modal_cfg = entry.modal
|
|
762
|
+
if modal_cfg is None:
|
|
763
|
+
raise click.ClickException(f"Task app '{entry.app_id}' does not define Modal deployment settings")
|
|
764
|
+
|
|
765
|
+
env_paths = _determine_env_files(entry, env_file)
|
|
766
|
+
click.echo('Using env file(s): ' + ', '.join(str(p) for p in env_paths))
|
|
767
|
+
_run_modal_with_entry(entry, modal_cfg, modal_cli, modal_name, env_paths, command="serve")
|
|
768
|
+
|
|
769
|
+
@click.group(
|
|
770
|
+
name='task-app',
|
|
771
|
+
help='Utilities for serving and deploying Synth task apps.'
|
|
772
|
+
)
|
|
773
|
+
def task_app_group() -> None:
|
|
774
|
+
pass
|
|
775
|
+
|
|
776
|
+
|
|
777
|
+
@task_app_group.command('list')
|
|
778
|
+
def list_apps() -> None:
|
|
779
|
+
"""List registered task apps."""
|
|
780
|
+
|
|
781
|
+
entries = registry.list()
|
|
782
|
+
if not entries:
|
|
783
|
+
click.echo("No task apps registered.")
|
|
784
|
+
return
|
|
785
|
+
for entry in entries:
|
|
786
|
+
aliases = f" (aliases: {', '.join(entry.aliases)})" if entry.aliases else ""
|
|
787
|
+
click.echo(f"- {entry.app_id}{aliases}: {entry.description}")
|
|
788
|
+
def _load_env_files_into_process(paths: Sequence[str]) -> None:
|
|
789
|
+
for p in paths:
|
|
790
|
+
try:
|
|
791
|
+
txt = Path(p).expanduser().read_text()
|
|
792
|
+
except Exception:
|
|
793
|
+
continue
|
|
794
|
+
for line in txt.splitlines():
|
|
795
|
+
if not line or line.startswith('#') or '=' not in line:
|
|
796
|
+
continue
|
|
797
|
+
k, v = line.split('=', 1)
|
|
798
|
+
key = k.strip()
|
|
799
|
+
val = v.strip().strip('"').strip("'")
|
|
800
|
+
# Load into process, but allow overriding if the current value is empty
|
|
801
|
+
if key:
|
|
802
|
+
current = os.environ.get(key)
|
|
803
|
+
if current is None or not str(current).strip():
|
|
804
|
+
os.environ[key] = val
|
|
805
|
+
|
|
806
|
+
|
|
807
|
+
|
|
808
|
+
@click.command('serve')
|
|
809
|
+
@click.argument('app_id', type=str, required=False)
|
|
810
|
+
@click.option('--host', default='0.0.0.0', show_default=True)
|
|
811
|
+
@click.option('--port', default=8001, show_default=True, type=int)
|
|
812
|
+
@click.option('--env-file', multiple=True, type=click.Path(), help='Extra .env files to load')
|
|
813
|
+
@click.option('--reload/--no-reload', 'reload_flag', default=False, help='Enable uvicorn auto-reload')
|
|
814
|
+
@click.option('--force/--no-force', 'force', default=False, help='Kill any process already bound to the selected port before starting')
|
|
815
|
+
@click.option('--trace', 'trace_dir', type=click.Path(), default=None, help='Enable tracing and write SFT JSONL files to this directory')
|
|
816
|
+
@click.option('--trace-db', 'trace_db', type=click.Path(), default=None, help='Override local trace DB path (maps to SQLD_DB_PATH)')
|
|
817
|
+
def serve_command(
|
|
818
|
+
app_id: str | None,
|
|
819
|
+
host: str,
|
|
820
|
+
port: int,
|
|
821
|
+
env_file: Sequence[str],
|
|
822
|
+
reload_flag: bool,
|
|
823
|
+
force: bool,
|
|
824
|
+
trace_dir: str | None,
|
|
825
|
+
trace_db: str | None,
|
|
826
|
+
) -> None:
|
|
827
|
+
choice = _select_app_choice(app_id, purpose="serve")
|
|
828
|
+
entry = choice.ensure_entry()
|
|
829
|
+
_serve_entry(entry, host, port, env_file, reload_flag, force, trace_dir=trace_dir, trace_db=trace_db)
|
|
830
|
+
|
|
831
|
+
|
|
832
|
+
@task_app_group.command('serve')
|
|
833
|
+
@click.argument('app_id', type=str, required=False)
|
|
834
|
+
@click.option('--host', default='0.0.0.0', show_default=True)
|
|
835
|
+
@click.option('--port', default=8001, show_default=True, type=int)
|
|
836
|
+
@click.option('--env-file', multiple=True, type=click.Path(), help='Extra .env files to load')
|
|
837
|
+
@click.option('--reload/--no-reload', 'reload_flag', default=False, help='Enable uvicorn auto-reload')
|
|
838
|
+
@click.option('--force/--no-force', 'force', default=False, help='Kill any process already bound to the selected port before starting')
|
|
839
|
+
@click.option('--trace', 'trace_dir', type=click.Path(), default=None, help='Enable tracing and write SFT JSONL files to this directory')
|
|
840
|
+
@click.option('--trace-db', 'trace_db', type=click.Path(), default=None, help='Override local trace DB path (maps to SQLD_DB_PATH)')
|
|
841
|
+
def serve_task_group(
|
|
842
|
+
app_id: str | None,
|
|
843
|
+
host: str,
|
|
844
|
+
port: int,
|
|
845
|
+
env_file: Sequence[str],
|
|
846
|
+
reload_flag: bool,
|
|
847
|
+
force: bool,
|
|
848
|
+
trace_dir: str | None,
|
|
849
|
+
trace_db: str | None,
|
|
850
|
+
) -> None:
|
|
851
|
+
choice = _select_app_choice(app_id, purpose="serve")
|
|
852
|
+
entry = choice.ensure_entry()
|
|
853
|
+
_serve_entry(entry, host, port, env_file, reload_flag, force, trace_dir=trace_dir, trace_db=trace_db)
|
|
854
|
+
|
|
855
|
+
def _determine_env_files(entry: TaskAppEntry, user_env_files: Sequence[str]) -> list[Path]:
|
|
856
|
+
resolved: list[Path] = []
|
|
857
|
+
for candidate in user_env_files:
|
|
858
|
+
p = Path(candidate).expanduser()
|
|
859
|
+
if not p.exists():
|
|
860
|
+
raise click.ClickException(f"Env file not found: {p}")
|
|
861
|
+
resolved.append(p)
|
|
862
|
+
if resolved:
|
|
863
|
+
return resolved
|
|
864
|
+
|
|
865
|
+
defaults = [Path(path).expanduser() for path in (entry.env_files or []) if Path(path).expanduser().exists()]
|
|
866
|
+
if defaults:
|
|
867
|
+
return defaults
|
|
868
|
+
|
|
869
|
+
env_candidates = sorted(REPO_ROOT.glob('**/*.env'))
|
|
870
|
+
if not env_candidates:
|
|
871
|
+
raise click.ClickException('No env file found. Pass --env-file explicitly.')
|
|
872
|
+
|
|
873
|
+
click.echo('Select env file to load:')
|
|
874
|
+
for idx, path in enumerate(env_candidates, start=1):
|
|
875
|
+
click.echo(f" {idx}) {path}")
|
|
876
|
+
choice = click.prompt('Enter choice', type=click.IntRange(1, len(env_candidates)))
|
|
877
|
+
return [env_candidates[choice - 1]]
|
|
878
|
+
|
|
879
|
+
|
|
880
|
+
def _ensure_port_free(port: int, host: str, *, force: bool) -> None:
|
|
881
|
+
import os
|
|
882
|
+
import socket
|
|
883
|
+
import subprocess
|
|
884
|
+
import time
|
|
885
|
+
|
|
886
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
887
|
+
in_use = s.connect_ex((host, port)) == 0
|
|
888
|
+
if not in_use:
|
|
889
|
+
return
|
|
890
|
+
|
|
891
|
+
try:
|
|
892
|
+
out = subprocess.run(["lsof", "-ti", f"TCP:{port}"], capture_output=True, text=True, check=False)
|
|
893
|
+
pids = [pid for pid in out.stdout.strip().splitlines() if pid]
|
|
894
|
+
except FileNotFoundError:
|
|
895
|
+
pids = []
|
|
896
|
+
|
|
897
|
+
if not force:
|
|
898
|
+
message = f"Port {port} appears to be in use"
|
|
899
|
+
if pids:
|
|
900
|
+
message += f" (PIDs: {', '.join(pids)})"
|
|
901
|
+
raise click.ClickException(message)
|
|
902
|
+
|
|
903
|
+
for pid in pids:
|
|
904
|
+
try:
|
|
905
|
+
os.kill(int(pid), signal.SIGTERM)
|
|
906
|
+
except Exception as exc:
|
|
907
|
+
raise click.ClickException(f'Failed to terminate PID {pid}: {exc}')
|
|
908
|
+
|
|
909
|
+
time.sleep(0.5)
|
|
910
|
+
|
|
911
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
912
|
+
still_in_use = s.connect_ex((host, port)) == 0
|
|
913
|
+
|
|
914
|
+
if still_in_use:
|
|
915
|
+
for pid in pids:
|
|
916
|
+
try:
|
|
917
|
+
os.kill(int(pid), signal.SIGKILL)
|
|
918
|
+
except Exception as exc:
|
|
919
|
+
raise click.ClickException(f'Failed to force terminate PID {pid}: {exc}')
|
|
920
|
+
time.sleep(0.5)
|
|
921
|
+
|
|
922
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
923
|
+
in_use_after = s.connect_ex((host, port)) == 0
|
|
924
|
+
if in_use_after:
|
|
925
|
+
raise click.ClickException(f'Port {port} is still in use after attempting to terminate processes.')
|
|
926
|
+
|
|
927
|
+
def _serve_entry(
|
|
928
|
+
entry: TaskAppEntry,
|
|
929
|
+
host: str,
|
|
930
|
+
port: int,
|
|
931
|
+
env_file: Sequence[str],
|
|
932
|
+
reload_flag: bool,
|
|
933
|
+
force: bool,
|
|
934
|
+
*,
|
|
935
|
+
trace_dir: str | None = None,
|
|
936
|
+
trace_db: str | None = None,
|
|
937
|
+
) -> None:
|
|
938
|
+
env_files = list(entry.env_files)
|
|
939
|
+
env_files.extend(env_file)
|
|
940
|
+
|
|
941
|
+
trace_enabled = trace_dir is not None or trace_db is not None
|
|
942
|
+
if trace_enabled:
|
|
943
|
+
os.environ['TASKAPP_TRACING_ENABLED'] = '1'
|
|
944
|
+
if trace_dir is not None:
|
|
945
|
+
dir_path = Path(trace_dir).expanduser()
|
|
946
|
+
try:
|
|
947
|
+
dir_path.mkdir(parents=True, exist_ok=True)
|
|
948
|
+
except Exception as exc:
|
|
949
|
+
raise click.ClickException(f"Failed to create trace directory {dir_path}: {exc}") from exc
|
|
950
|
+
os.environ['TASKAPP_SFT_OUTPUT_DIR'] = str(dir_path)
|
|
951
|
+
click.echo(f"Tracing enabled. SFT JSONL will be written to {dir_path}")
|
|
952
|
+
if trace_db is not None:
|
|
953
|
+
db_path = Path(trace_db).expanduser()
|
|
954
|
+
os.environ['SQLD_DB_PATH'] = str(db_path)
|
|
955
|
+
os.environ.pop('TURSO_LOCAL_DB_URL', None)
|
|
956
|
+
click.echo(f"Tracing DB path set to {db_path}")
|
|
957
|
+
from synth_ai.tracing_v3.config import CONFIG as TRACE_CONFIG
|
|
958
|
+
# recompute db_url based on current environment
|
|
959
|
+
new_db_url = os.getenv('TURSO_LOCAL_DB_URL') or TRACE_CONFIG.db_url
|
|
960
|
+
TRACE_CONFIG.db_url = new_db_url
|
|
961
|
+
if new_db_url:
|
|
962
|
+
os.environ['TURSO_LOCAL_DB_URL'] = new_db_url
|
|
963
|
+
click.echo(f"Tracing DB URL resolved to {new_db_url}")
|
|
964
|
+
elif os.getenv('TASKAPP_TRACING_ENABLED'):
|
|
965
|
+
click.echo("Tracing enabled via environment variables")
|
|
966
|
+
|
|
967
|
+
_ensure_port_free(port, host, force=force)
|
|
968
|
+
|
|
969
|
+
_preflight_env_key()
|
|
970
|
+
|
|
971
|
+
run_task_app(
|
|
972
|
+
entry.config_factory,
|
|
973
|
+
host=host,
|
|
974
|
+
port=port,
|
|
975
|
+
reload=reload_flag,
|
|
976
|
+
env_files=env_files,
|
|
977
|
+
)
|
|
978
|
+
|
|
979
|
+
|
|
980
|
+
@task_app_group.command('deploy')
|
|
981
|
+
@click.argument("app_id", type=str, required=False)
|
|
982
|
+
@click.option("--name", "modal_name", default=None, help="Override Modal app name")
|
|
983
|
+
@click.option("--dry-run", is_flag=True, help="Print modal deploy command without executing")
|
|
984
|
+
@click.option("--modal-cli", default="modal", help="Path to modal CLI executable")
|
|
985
|
+
@click.option('--env-file', multiple=True, type=click.Path(), help='Env file to load into the container (can be repeated)')
|
|
986
|
+
def deploy_app(app_id: str | None, modal_name: str | None, dry_run: bool, modal_cli: str, env_file: Sequence[str]) -> None:
|
|
987
|
+
"""Deploy a task app to Modal."""
|
|
988
|
+
|
|
989
|
+
choice = _select_app_choice(app_id, purpose="deploy")
|
|
990
|
+
|
|
991
|
+
if choice.modal_script:
|
|
992
|
+
env_paths = _resolve_env_paths_for_script(choice.modal_script, env_file)
|
|
993
|
+
click.echo('Using env file(s): ' + ', '.join(str(p) for p in env_paths))
|
|
994
|
+
_run_modal_script(choice.modal_script, modal_cli, "deploy", env_paths, modal_name=modal_name, dry_run=dry_run)
|
|
995
|
+
return
|
|
996
|
+
|
|
997
|
+
entry = choice.ensure_entry()
|
|
998
|
+
_deploy_entry(entry, modal_name, dry_run, modal_cli, env_file)
|
|
999
|
+
|
|
1000
|
+
@task_app_group.command('modal-serve')
|
|
1001
|
+
@click.argument('app_id', type=str, required=False)
|
|
1002
|
+
@click.option('--modal-cli', default='modal', help='Path to modal CLI executable')
|
|
1003
|
+
@click.option('--name', 'modal_name', default=None, help='Override Modal app name (optional)')
|
|
1004
|
+
@click.option('--env-file', multiple=True, type=click.Path(), help='Env file to load into the container (can be repeated)')
|
|
1005
|
+
def modal_serve_app(app_id: str | None, modal_cli: str, modal_name: str | None, env_file: Sequence[str]) -> None:
|
|
1006
|
+
choice = _select_app_choice(app_id, purpose="modal-serve")
|
|
1007
|
+
|
|
1008
|
+
if choice.modal_script:
|
|
1009
|
+
env_paths = _resolve_env_paths_for_script(choice.modal_script, env_file)
|
|
1010
|
+
click.echo('Using env file(s): ' + ', '.join(str(p) for p in env_paths))
|
|
1011
|
+
_run_modal_script(choice.modal_script, modal_cli, "serve", env_paths, modal_name=modal_name)
|
|
1012
|
+
return
|
|
1013
|
+
|
|
1014
|
+
entry = choice.ensure_entry()
|
|
1015
|
+
_modal_serve_entry(entry, modal_name, modal_cli, env_file)
|
|
1016
|
+
|
|
1017
|
+
|
|
1018
|
+
def _write_modal_entrypoint(
|
|
1019
|
+
entry: TaskAppEntry,
|
|
1020
|
+
modal_cfg: ModalDeploymentConfig,
|
|
1021
|
+
override_name: str | None,
|
|
1022
|
+
*,
|
|
1023
|
+
dotenv_paths: Sequence[str] | None = None,
|
|
1024
|
+
) -> Path:
|
|
1025
|
+
modal_name = override_name or modal_cfg.app_name
|
|
1026
|
+
|
|
1027
|
+
module_name = entry.config_factory.__module__
|
|
1028
|
+
dotenv_paths = [str(Path(path)) for path in (dotenv_paths or [])]
|
|
1029
|
+
|
|
1030
|
+
pip_packages = list(modal_cfg.pip_packages)
|
|
1031
|
+
|
|
1032
|
+
local_dirs = [(str(Path(src)), dst) for src, dst in modal_cfg.extra_local_dirs]
|
|
1033
|
+
secret_names = list(modal_cfg.secret_names)
|
|
1034
|
+
volume_mounts = [(name, mount) for name, mount in modal_cfg.volume_mounts]
|
|
1035
|
+
|
|
1036
|
+
script = f"""from __future__ import annotations
|
|
1037
|
+
|
|
1038
|
+
import importlib
|
|
1039
|
+
import sys
|
|
1040
|
+
sys.path.insert(0, '/opt/synth_ai_repo')
|
|
1041
|
+
|
|
1042
|
+
from modal import App, Image, Secret, Volume, asgi_app
|
|
1043
|
+
|
|
1044
|
+
from synth_ai.task.apps import registry
|
|
1045
|
+
from synth_ai.task.server import create_task_app
|
|
1046
|
+
|
|
1047
|
+
ENTRY_ID = {entry.app_id!r}
|
|
1048
|
+
MODAL_APP_NAME = {modal_name!r}
|
|
1049
|
+
MODULE_NAME = {module_name!r}
|
|
1050
|
+
DOTENV_PATHS = {dotenv_paths!r}
|
|
1051
|
+
|
|
1052
|
+
image = Image.debian_slim(python_version={modal_cfg.python_version!r})
|
|
1053
|
+
|
|
1054
|
+
pip_packages = {pip_packages!r}
|
|
1055
|
+
if pip_packages:
|
|
1056
|
+
image = image.pip_install(*pip_packages)
|
|
1057
|
+
|
|
1058
|
+
local_dirs = {local_dirs!r}
|
|
1059
|
+
for local_src, remote_dst in local_dirs:
|
|
1060
|
+
image = image.add_local_dir(local_src, remote_dst)
|
|
1061
|
+
|
|
1062
|
+
secrets = {secret_names!r}
|
|
1063
|
+
secret_objs = [Secret.from_name(name) for name in secrets]
|
|
1064
|
+
|
|
1065
|
+
if DOTENV_PATHS:
|
|
1066
|
+
secret_objs.extend(Secret.from_dotenv(path) for path in DOTENV_PATHS)
|
|
1067
|
+
|
|
1068
|
+
volume_mounts = {volume_mounts!r}
|
|
1069
|
+
volume_map = {{}}
|
|
1070
|
+
for vol_name, mount_path in volume_mounts:
|
|
1071
|
+
volume_map[mount_path] = Volume.from_name(vol_name, create_if_missing=True)
|
|
1072
|
+
|
|
1073
|
+
importlib.import_module(MODULE_NAME)
|
|
1074
|
+
|
|
1075
|
+
entry = registry.get(ENTRY_ID)
|
|
1076
|
+
modal_cfg = entry.modal
|
|
1077
|
+
if modal_cfg is None:
|
|
1078
|
+
raise RuntimeError("Modal configuration missing for task app {entry.app_id}")
|
|
1079
|
+
|
|
1080
|
+
app = App(MODAL_APP_NAME)
|
|
1081
|
+
|
|
1082
|
+
@app.function(
|
|
1083
|
+
image=image,
|
|
1084
|
+
timeout={modal_cfg.timeout},
|
|
1085
|
+
memory={modal_cfg.memory},
|
|
1086
|
+
cpu={modal_cfg.cpu},
|
|
1087
|
+
min_containers={modal_cfg.min_containers},
|
|
1088
|
+
max_containers={modal_cfg.max_containers},
|
|
1089
|
+
secrets=secret_objs,
|
|
1090
|
+
volumes=volume_map,
|
|
1091
|
+
)
|
|
1092
|
+
@asgi_app()
|
|
1093
|
+
def fastapi_app():
|
|
1094
|
+
config = entry.config_factory()
|
|
1095
|
+
return create_task_app(config)
|
|
1096
|
+
"""
|
|
1097
|
+
|
|
1098
|
+
tmp = tempfile.NamedTemporaryFile("w", suffix=f"_{entry.app_id}_modal.py", delete=False)
|
|
1099
|
+
tmp.write(script)
|
|
1100
|
+
tmp.flush()
|
|
1101
|
+
tmp.close()
|
|
1102
|
+
return Path(tmp.name)
|
|
1103
|
+
|
|
1104
|
+
|
|
1105
|
+
def register(cli: click.Group) -> None:
|
|
1106
|
+
cli.add_command(serve_command)
|
|
1107
|
+
cli.add_command(task_app_group)
|