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.
Files changed (42) hide show
  1. synth_ai/api/train/__init__.py +5 -0
  2. synth_ai/api/train/builders.py +165 -0
  3. synth_ai/api/train/cli.py +450 -0
  4. synth_ai/api/train/config_finder.py +168 -0
  5. synth_ai/api/train/env_resolver.py +302 -0
  6. synth_ai/api/train/pollers.py +66 -0
  7. synth_ai/api/train/task_app.py +193 -0
  8. synth_ai/api/train/utils.py +232 -0
  9. synth_ai/cli/__init__.py +23 -0
  10. synth_ai/cli/rl_demo.py +18 -6
  11. synth_ai/cli/root.py +38 -6
  12. synth_ai/cli/task_apps.py +1107 -0
  13. synth_ai/demo_registry.py +258 -0
  14. synth_ai/demos/core/cli.py +147 -111
  15. synth_ai/demos/demo_task_apps/__init__.py +7 -1
  16. synth_ai/demos/demo_task_apps/math/config.toml +55 -110
  17. synth_ai/demos/demo_task_apps/math/modal_task_app.py +157 -21
  18. synth_ai/demos/demo_task_apps/math/task_app_entry.py +39 -0
  19. synth_ai/task/__init__.py +94 -1
  20. synth_ai/task/apps/__init__.py +88 -0
  21. synth_ai/task/apps/grpo_crafter.py +438 -0
  22. synth_ai/task/apps/math_single_step.py +852 -0
  23. synth_ai/task/auth.py +153 -0
  24. synth_ai/task/client.py +165 -0
  25. synth_ai/task/contracts.py +29 -14
  26. synth_ai/task/datasets.py +105 -0
  27. synth_ai/task/errors.py +49 -0
  28. synth_ai/task/json.py +77 -0
  29. synth_ai/task/proxy.py +258 -0
  30. synth_ai/task/rubrics.py +212 -0
  31. synth_ai/task/server.py +398 -0
  32. synth_ai/task/tracing_utils.py +79 -0
  33. synth_ai/task/vendors.py +61 -0
  34. synth_ai/tracing_v3/session_tracer.py +13 -5
  35. synth_ai/tracing_v3/storage/base.py +10 -12
  36. synth_ai/tracing_v3/turso/manager.py +20 -6
  37. {synth_ai-0.2.8.dev12.dist-info → synth_ai-0.2.9.dev0.dist-info}/METADATA +3 -2
  38. {synth_ai-0.2.8.dev12.dist-info → synth_ai-0.2.9.dev0.dist-info}/RECORD +42 -18
  39. {synth_ai-0.2.8.dev12.dist-info → synth_ai-0.2.9.dev0.dist-info}/WHEEL +0 -0
  40. {synth_ai-0.2.8.dev12.dist-info → synth_ai-0.2.9.dev0.dist-info}/entry_points.txt +0 -0
  41. {synth_ai-0.2.8.dev12.dist-info → synth_ai-0.2.9.dev0.dist-info}/licenses/LICENSE +0 -0
  42. {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)