lightcone-cli 0.2.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.
Files changed (46) hide show
  1. lightcone/cli/__init__.py +16 -0
  2. lightcone/cli/claude/lightcone/agents/lc-extractor.md +114 -0
  3. lightcone/cli/claude/lightcone/guides/astra-reference.md +290 -0
  4. lightcone/cli/claude/lightcone/guides/lightcone-cli-reference.md +75 -0
  5. lightcone/cli/claude/lightcone/guides/ui-brand.md +86 -0
  6. lightcone/cli/claude/lightcone/hooks/langfuse_git_commit_hook.py +303 -0
  7. lightcone/cli/claude/lightcone/hooks/langfuse_hook.py +894 -0
  8. lightcone/cli/claude/lightcone/hooks/langfuse_prepare_commit_msg.py +142 -0
  9. lightcone/cli/claude/lightcone/hooks/langfuse_session_init_hook.py +83 -0
  10. lightcone/cli/claude/lightcone/hooks/langfuse_utils.py +457 -0
  11. lightcone/cli/claude/lightcone/scripts/activate-venv.sh +44 -0
  12. lightcone/cli/claude/lightcone/scripts/check-lc-run.sh +140 -0
  13. lightcone/cli/claude/lightcone/scripts/session-start.sh +140 -0
  14. lightcone/cli/claude/lightcone/scripts/validate-on-save.sh +77 -0
  15. lightcone/cli/claude/lightcone/skills/lc-build/SKILL.md +92 -0
  16. lightcone/cli/claude/lightcone/skills/lc-build/assets/loop-prompt.md +92 -0
  17. lightcone/cli/claude/lightcone/skills/lc-build/scripts/setup-lc-build.sh +240 -0
  18. lightcone/cli/claude/lightcone/skills/lc-feedback/SKILL.md +94 -0
  19. lightcone/cli/claude/lightcone/skills/lc-migrate/SKILL.md +98 -0
  20. lightcone/cli/claude/lightcone/skills/lc-new/SKILL.md +183 -0
  21. lightcone/cli/claude/lightcone/skills/lc-verify/SKILL.md +53 -0
  22. lightcone/cli/claude/lightcone/templates/CLAUDE.md +32 -0
  23. lightcone/cli/commands.py +2327 -0
  24. lightcone/cli/plugin.py +34 -0
  25. lightcone/engine/__init__.py +42 -0
  26. lightcone/engine/assets.py +418 -0
  27. lightcone/engine/container.py +370 -0
  28. lightcone/engine/io_manager.py +27 -0
  29. lightcone/engine/runner.py +1017 -0
  30. lightcone/engine/site_registry.py +142 -0
  31. lightcone/engine/status.py +135 -0
  32. lightcone/engine/targets.py +68 -0
  33. lightcone/engine/tree.py +245 -0
  34. lightcone/eval/__init__.py +25 -0
  35. lightcone/eval/build.py +148 -0
  36. lightcone/eval/cli.py +176 -0
  37. lightcone/eval/graders.py +192 -0
  38. lightcone/eval/harness.py +265 -0
  39. lightcone/eval/models.py +117 -0
  40. lightcone/eval/report.py +214 -0
  41. lightcone/eval/sandbox.py +394 -0
  42. lightcone_cli-0.2.0.dist-info/METADATA +16 -0
  43. lightcone_cli-0.2.0.dist-info/RECORD +46 -0
  44. lightcone_cli-0.2.0.dist-info/WHEEL +4 -0
  45. lightcone_cli-0.2.0.dist-info/entry_points.txt +2 -0
  46. lightcone_cli-0.2.0.dist-info/licenses/LICENSE +29 -0
@@ -0,0 +1,1017 @@
1
+ """ASTRA Container Runner — executes recipes in Docker/Podman, locally, or via SLURM."""
2
+ from __future__ import annotations
3
+
4
+ import logging
5
+ import os
6
+ import re
7
+ import shlex
8
+ import subprocess
9
+ import sys
10
+ import time
11
+ from dataclasses import dataclass, field
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ # Maximum number of characters to keep from stdout/stderr for metadata.
18
+ _TAIL_CHARS = 2000
19
+
20
+
21
+ def _run_streaming(
22
+ cmd: list[str] | str,
23
+ *,
24
+ shell: bool = False,
25
+ cwd: str | None = None,
26
+ env: dict[str, str] | None = None,
27
+ ) -> tuple[int, str, str]:
28
+ """Run a command, streaming stdout/stderr to the terminal in real time.
29
+
30
+ Returns ``(returncode, stdout_tail, stderr_tail)`` where each tail
31
+ contains at most the last ``_TAIL_CHARS`` characters of output.
32
+ """
33
+ import selectors
34
+
35
+ stream_env = dict(env) if env else dict(os.environ)
36
+ stream_env["PYTHONUNBUFFERED"] = "1"
37
+
38
+ proc = subprocess.Popen(
39
+ cmd,
40
+ stdout=subprocess.PIPE,
41
+ stderr=subprocess.PIPE,
42
+ text=True,
43
+ shell=shell,
44
+ cwd=cwd,
45
+ env=stream_env,
46
+ )
47
+
48
+ sel = selectors.DefaultSelector()
49
+ sel.register(proc.stdout, selectors.EVENT_READ)
50
+ sel.register(proc.stderr, selectors.EVENT_READ)
51
+
52
+ stdout_tail: list[str] = []
53
+ stderr_tail: list[str] = []
54
+ stdout_len = 0
55
+ stderr_len = 0
56
+
57
+ open_streams = 2
58
+ while open_streams > 0:
59
+ for key, _ in sel.select():
60
+ line = key.fileobj.readline()
61
+ if not line:
62
+ sel.unregister(key.fileobj)
63
+ open_streams -= 1
64
+ continue
65
+ if key.fileobj is proc.stdout:
66
+ sys.stdout.write(line)
67
+ sys.stdout.flush()
68
+ stdout_tail.append(line)
69
+ stdout_len += len(line)
70
+ while stdout_len > _TAIL_CHARS and len(stdout_tail) > 1:
71
+ stdout_len -= len(stdout_tail.pop(0))
72
+ else:
73
+ sys.stderr.write(line)
74
+ sys.stderr.flush()
75
+ stderr_tail.append(line)
76
+ stderr_len += len(line)
77
+ while stderr_len > _TAIL_CHARS and len(stderr_tail) > 1:
78
+ stderr_len -= len(stderr_tail.pop(0))
79
+
80
+ proc.wait()
81
+ sel.close()
82
+ return proc.returncode, "".join(stdout_tail), "".join(stderr_tail)
83
+
84
+
85
+
86
+ def _find_venv(cwd: str | None, project_root: Path) -> Path | None:
87
+ """Find .venv by checking cwd first, then walking up to project_root."""
88
+ if cwd:
89
+ cwd_path = Path(cwd)
90
+ venv = cwd_path / ".venv"
91
+ if (venv / "bin" / "python").exists():
92
+ return venv
93
+ # Walk up to project_root
94
+ current = cwd_path.parent
95
+ root_resolved = project_root.resolve()
96
+ while current >= root_resolved:
97
+ venv = current / ".venv"
98
+ if (venv / "bin" / "python").exists():
99
+ return venv
100
+ if current == root_resolved:
101
+ break
102
+ current = current.parent
103
+
104
+ # Fall back to project root
105
+ venv = project_root / ".venv"
106
+ if (venv / "bin" / "python").exists():
107
+ return venv
108
+ return None
109
+
110
+
111
+ def _substitute_python(command: str, python_path: str) -> str:
112
+ """Replace a leading ``python `` with a specific interpreter path."""
113
+ if command.startswith("python "):
114
+ return python_path + command[len("python"):]
115
+ return command
116
+
117
+
118
+ @dataclass
119
+ class ExecutionResult:
120
+ """Result of executing a recipe."""
121
+ exit_code: int
122
+ output_path: Path
123
+ metadata: dict[str, Any] = field(default_factory=dict)
124
+
125
+
126
+ def _build_cli_args(params: dict[str, Any], universe_id: str) -> list[str]:
127
+ """Build CLI arguments from universe decisions."""
128
+ args = ["--universe", universe_id]
129
+ for key, value in params.items():
130
+ args.extend([f"--{key}", str(value)])
131
+ return args
132
+
133
+
134
+ def translate_resources_to_docker_flags(resources: dict[str, Any]) -> list[str]:
135
+ """Translate ASTRA resource requirements to Docker CLI flags."""
136
+ flags: list[str] = []
137
+ if cpus := resources.get("cpus"):
138
+ flags.append(f"--cpus={cpus}")
139
+ if memory := resources.get("memory"):
140
+ flags.append(f"--memory={memory.lower()}")
141
+ if gpus := resources.get("gpus"):
142
+ flags.append(f"--gpus={gpus}")
143
+ return flags
144
+
145
+
146
+ class ASTRAContainerRunner:
147
+ """Executes ASTRA recipes via Docker, local subprocess, or SLURM.
148
+
149
+ When backend is "docker", attempts Docker execution first. If Docker
150
+ fails (missing image, daemon not running, non-zero exit), falls back to
151
+ local subprocess execution with a warning.
152
+ """
153
+
154
+ def __init__(
155
+ self,
156
+ project_root: str,
157
+ backend: str = "docker",
158
+ default_container: str | None = None,
159
+ target_config: dict[str, Any] | None = None,
160
+ container_runtime: str | None = None,
161
+ ):
162
+ """Initialise an ASTRA container runner.
163
+
164
+ Args:
165
+ project_root: Absolute path to the ASTRA project directory.
166
+ All recipe commands are executed with this as their working
167
+ directory unless *cwd_override* is supplied at call time.
168
+ backend: Execution backend to use. One of ``"docker"``,
169
+ ``"local"``, ``"venv"``, or ``"slurm"``. The ``"docker"``
170
+ backend automatically falls back to ``"venv"`` (or
171
+ ``"local"``) when the container run fails.
172
+ default_container: Analysis-level container image resolved from
173
+ ``astra.yaml``. Per-recipe containers override this value.
174
+ target_config: Parsed SLURM target configuration dict (from
175
+ ``~/.lightcone/targets/<name>.yaml``). Used only when *backend*
176
+ is ``"slurm"``.
177
+ container_runtime: Local container runtime binary name
178
+ (``"docker"`` or ``"podman"``). When ``None``, the runner
179
+ uses whatever is available on ``PATH``.
180
+ """
181
+ self.project_root = Path(project_root)
182
+ self.backend = backend
183
+ self.default_container = default_container
184
+ self.target_config = target_config or {}
185
+ self.container_runtime = container_runtime
186
+ self._venv_deps_checked = False
187
+
188
+ def execute(
189
+ self,
190
+ command: str,
191
+ output_id: str,
192
+ universe_id: str,
193
+ container: str | None = None,
194
+ inputs: list[str] | None = None,
195
+ resources: dict[str, Any] | None = None,
196
+ params: dict[str, Any] | None = None,
197
+ external_inputs: dict[str, str] | None = None,
198
+ cwd_override: str | None = None,
199
+ ) -> ExecutionResult:
200
+ """Execute a recipe, dispatching to the configured backend.
201
+
202
+ For the "docker" backend, falls back to local execution when Docker
203
+ is unavailable or the container run fails.
204
+ """
205
+ cli_args = _build_cli_args(params or {}, universe_id)
206
+ full_command = (command + " " + " ".join(cli_args)).strip()
207
+ results_dir = self.project_root / "results" / universe_id
208
+ results_dir.mkdir(parents=True, exist_ok=True)
209
+
210
+ # Effective working directory: cwd_override (for sub-analysis recipes)
211
+ # or project_root
212
+ effective_cwd = cwd_override or str(self.project_root)
213
+
214
+ if self.backend == "local":
215
+ return self._run_local(
216
+ full_command, output_id, universe_id, cwd=effective_cwd,
217
+ )
218
+
219
+ if self.backend == "slurm":
220
+ return self._run_slurm(
221
+ command=full_command,
222
+ container=container or self.default_container,
223
+ input_ids=inputs or [],
224
+ output_id=output_id,
225
+ universe_id=universe_id,
226
+ resources=resources or {},
227
+ external_inputs=external_inputs,
228
+ cwd=effective_cwd,
229
+ )
230
+
231
+ if self.backend == "venv":
232
+ return self._run_venv(
233
+ full_command, output_id, universe_id, cwd=effective_cwd,
234
+ )
235
+
236
+ # Container backend — try container runtime, fall back to venv or local.
237
+ # Note: the explicit "local" backend (set via target config) skips dep
238
+ # installation and runs in the current Python env. The implicit fallback
239
+ # here goes to _run_venv (with dep installation) when .venv is present,
240
+ # and only falls back to _run_local when .venv is absent.
241
+ effective_container = container or self.default_container
242
+ if effective_container:
243
+ result = self._run_container(
244
+ command=full_command,
245
+ container=effective_container,
246
+ universe_id=universe_id,
247
+ resources=resources or {},
248
+ runtime=self.container_runtime or "docker",
249
+ )
250
+ if result.exit_code == 0:
251
+ return result
252
+ # Container failed — fall back to venv (or local if venv is absent)
253
+ logger.warning(
254
+ "%s execution failed for '%s' (exit code %d). "
255
+ "Falling back to venv execution.\n stderr: %s",
256
+ self.container_runtime or "docker", output_id, result.exit_code,
257
+ result.metadata.get("stderr", "")[:200],
258
+ )
259
+
260
+ venv_python = self.project_root / ".venv" / "bin" / "python"
261
+ if venv_python.exists():
262
+ return self._run_venv(
263
+ command=full_command,
264
+ output_id=output_id,
265
+ universe_id=universe_id,
266
+ warn=effective_container is not None,
267
+ cwd=effective_cwd,
268
+ )
269
+
270
+ # No .venv available — fall back to the current Python environment so
271
+ # that projects without a venv (e.g. pre-existing installs that predate
272
+ # lc init) continue to work rather than surfacing a confusing error.
273
+ logger.warning(
274
+ "No .venv found for '%s'; executing locally without dep isolation. "
275
+ "Run 'lc init' to create a project venv with dependencies installed.",
276
+ output_id,
277
+ )
278
+ return self._run_local(
279
+ command=full_command,
280
+ output_id=output_id,
281
+ universe_id=universe_id,
282
+ warn=effective_container is not None,
283
+ cwd=effective_cwd,
284
+ )
285
+
286
+ def _run_container(
287
+ self,
288
+ command: str,
289
+ container: str,
290
+ universe_id: str,
291
+ resources: dict[str, Any],
292
+ runtime: str = "docker",
293
+ ) -> ExecutionResult:
294
+ """Execute a recipe in a container (Docker or Podman).
295
+
296
+ Mounts the project root at /workspace so scripts can read data and
297
+ write results using their normal relative paths.
298
+ """
299
+ cmd = [runtime, "run", "--rm"]
300
+ cmd.extend(translate_resources_to_docker_flags(resources))
301
+ cmd.extend([
302
+ "-v", f"{self.project_root}:/workspace",
303
+ "-w", "/workspace",
304
+ container,
305
+ "sh", "-c", command,
306
+ ])
307
+
308
+ try:
309
+ returncode, stdout_tail, stderr_tail = _run_streaming(cmd)
310
+ except FileNotFoundError:
311
+ return ExecutionResult(
312
+ exit_code=127,
313
+ output_path=self.project_root / "results" / universe_id,
314
+ metadata={"stderr": f"{runtime}: command not found"},
315
+ )
316
+
317
+ return ExecutionResult(
318
+ exit_code=returncode,
319
+ output_path=self.project_root / "results" / universe_id,
320
+ metadata={
321
+ "stdout": stdout_tail,
322
+ "stderr": stderr_tail,
323
+ "backend": runtime,
324
+ "container_command": " ".join(cmd),
325
+ },
326
+ )
327
+
328
+ def _run_local(
329
+ self,
330
+ command: str,
331
+ output_id: str,
332
+ universe_id: str,
333
+ warn: bool = False,
334
+ cwd: str | None = None,
335
+ ) -> ExecutionResult:
336
+ """Execute a recipe as a local subprocess.
337
+
338
+ Uses the current Python environment. Decision parameters are passed
339
+ as CLI arguments.
340
+ """
341
+ if warn:
342
+ logger.warning(
343
+ "Executing '%s' locally (no container). "
344
+ "Results may differ from containerised execution.",
345
+ output_id,
346
+ )
347
+
348
+ full_command = _substitute_python(command, sys.executable)
349
+
350
+ returncode, stdout_tail, stderr_tail = _run_streaming(
351
+ full_command, shell=True, cwd=cwd or str(self.project_root),
352
+ )
353
+
354
+ output_path = self.project_root / "results" / universe_id
355
+ return ExecutionResult(
356
+ exit_code=returncode,
357
+ output_path=output_path,
358
+ metadata={
359
+ "stdout": stdout_tail,
360
+ "stderr": stderr_tail,
361
+ "backend": "local",
362
+ },
363
+ )
364
+
365
+ def _run_venv(
366
+ self,
367
+ command: str,
368
+ output_id: str,
369
+ universe_id: str,
370
+ warn: bool = False,
371
+ cwd: str | None = None,
372
+ ) -> ExecutionResult:
373
+ """Execute a recipe in the project's virtual environment.
374
+
375
+ Uses the ``.venv/`` created by ``lc init``. Ensures that
376
+ dependencies from ``requirements*.txt`` are installed before
377
+ running, using a hash-based marker to skip redundant installs.
378
+
379
+ For sub-analysis recipes, the venv is resolved by walking up from
380
+ the working directory to the project root.
381
+ """
382
+ if warn:
383
+ logger.warning(
384
+ "Executing '%s' in project venv. "
385
+ "Results may differ from containerised execution.",
386
+ output_id,
387
+ )
388
+
389
+ # Find venv: check cwd first, then walk up to project root
390
+ venv_path = _find_venv(cwd, self.project_root)
391
+
392
+ if venv_path is None:
393
+ return ExecutionResult(
394
+ exit_code=1,
395
+ output_path=self.project_root / "results" / universe_id,
396
+ metadata={
397
+ "stderr": (
398
+ "No .venv found in project root. "
399
+ "Run 'lc init' or create a virtual environment first."
400
+ ),
401
+ "backend": "venv",
402
+ },
403
+ )
404
+
405
+ venv_python = venv_path / "bin" / "python"
406
+
407
+ # Ensure dependencies are installed
408
+ self._ensure_venv_deps(venv_path)
409
+
410
+ full_command = _substitute_python(command, str(venv_python))
411
+
412
+ env = {
413
+ **os.environ,
414
+ "VIRTUAL_ENV": str(venv_path),
415
+ "PATH": f"{venv_path / 'bin'}:{os.environ.get('PATH', '')}",
416
+ }
417
+
418
+ returncode, stdout_tail, stderr_tail = _run_streaming(
419
+ full_command, shell=True, cwd=cwd or str(self.project_root), env=env,
420
+ )
421
+
422
+ output_path = self.project_root / "results" / universe_id
423
+ return ExecutionResult(
424
+ exit_code=returncode,
425
+ output_path=output_path,
426
+ metadata={
427
+ "stdout": stdout_tail,
428
+ "stderr": stderr_tail,
429
+ "backend": "venv",
430
+ "venv_path": str(venv_path),
431
+ },
432
+ )
433
+
434
+ def _ensure_venv_deps(self, venv_path: Path) -> None:
435
+ """Install requirements into venv if they have changed.
436
+
437
+ Computes a hash of all ``requirements*.txt`` files and compares
438
+ it to a marker file (``.venv/.deps-hash``). If the hash matches,
439
+ installation is skipped. Once checked successfully within this
440
+ runner instance, subsequent calls are no-ops.
441
+ """
442
+ if self._venv_deps_checked:
443
+ return
444
+
445
+ from lightcone.engine.container import find_dependency_files, hash_file_contents
446
+
447
+ dep_files = find_dependency_files(self.project_root)
448
+ req_files = [f for f in dep_files if f.name.startswith("requirements")]
449
+ if not req_files:
450
+ return
451
+
452
+ current_hash = hash_file_contents(req_files)
453
+
454
+ marker = venv_path / ".deps-hash"
455
+ if marker.exists() and marker.read_text().strip() == current_hash:
456
+ return
457
+
458
+ pip_path = venv_path / "bin" / "pip"
459
+ all_installed = True
460
+ for req_file in req_files:
461
+ logger.info("Installing dependencies from %s into .venv ...", req_file.name)
462
+ install_result = subprocess.run(
463
+ [str(pip_path), "install", "-r", str(req_file)],
464
+ capture_output=True,
465
+ text=True,
466
+ cwd=str(self.project_root),
467
+ )
468
+ if install_result.returncode != 0:
469
+ logger.warning(
470
+ "pip install -r %s failed: %s",
471
+ req_file.name, install_result.stderr[:200],
472
+ )
473
+ all_installed = False
474
+
475
+ # Only record the hash when all installs succeeded — a failed install
476
+ # that wrote the marker would be silently skipped on the next run.
477
+ if all_installed:
478
+ marker.write_text(current_hash + "\n")
479
+ self._venv_deps_checked = True
480
+
481
+ def _run_slurm_interactive(
482
+ self,
483
+ command: str,
484
+ container: str | None,
485
+ output_id: str,
486
+ universe_id: str,
487
+ resources: dict[str, Any],
488
+ external_inputs: dict[str, str] | None = None,
489
+ cwd: str | None = None,
490
+ ) -> ExecutionResult:
491
+ """Execute a recipe via srun inside an existing interactive allocation.
492
+
493
+ Runs synchronously — no job submission or polling needed.
494
+ """
495
+ effective_cwd = cwd or str(self.project_root)
496
+ scheduler = self.target_config.get("scheduler", {})
497
+ container_runtime = scheduler.get("container_runtime", "podman-hpc")
498
+
499
+ output_path = Path(effective_cwd) / "results" / universe_id
500
+ output_path.mkdir(parents=True, exist_ok=True)
501
+
502
+ # Build the execution command
503
+ if container and container_runtime == "podman-hpc":
504
+ exec_command = _podman_hpc_run_command(
505
+ command, container, self.project_root, resources, scheduler,
506
+ external_inputs=external_inputs,
507
+ )
508
+ else:
509
+ # No container — symlink external inputs into data/ directory
510
+ if external_inputs:
511
+ data_dir = Path(effective_cwd) / "data"
512
+ data_dir.mkdir(parents=True, exist_ok=True)
513
+ for input_id, source in sorted(external_inputs.items()):
514
+ link = data_dir / input_id
515
+ if link.is_symlink() or link.exists():
516
+ link.unlink()
517
+ link.symlink_to(source)
518
+ exec_command = command
519
+
520
+ cmd = ["srun", "bash", "-c", exec_command]
521
+
522
+ logger.info(
523
+ "Running %s/%s interactively (SLURM_JOB_ID=%s)",
524
+ output_id, universe_id, os.environ.get("SLURM_JOB_ID"),
525
+ )
526
+
527
+ try:
528
+ returncode, stdout_tail, stderr_tail = _run_streaming(
529
+ cmd, cwd=effective_cwd,
530
+ )
531
+ except FileNotFoundError:
532
+ return ExecutionResult(
533
+ exit_code=127,
534
+ output_path=output_path,
535
+ metadata={"stderr": "srun: command not found"},
536
+ )
537
+
538
+ return ExecutionResult(
539
+ exit_code=returncode,
540
+ output_path=output_path,
541
+ metadata={
542
+ "backend": "slurm-interactive",
543
+ "slurm_job_id": os.environ.get("SLURM_JOB_ID", ""),
544
+ "container_runtime": container_runtime if container else None,
545
+ "stdout": stdout_tail,
546
+ "stderr": stderr_tail,
547
+ },
548
+ )
549
+
550
+ def _run_slurm(
551
+ self,
552
+ command: str,
553
+ container: str | None,
554
+ input_ids: list[str],
555
+ output_id: str,
556
+ universe_id: str,
557
+ resources: dict[str, Any],
558
+ external_inputs: dict[str, str] | None = None,
559
+ cwd: str | None = None,
560
+ ) -> ExecutionResult:
561
+ """Execute a recipe via SLURM.
562
+
563
+ When ``SLURM_JOB_ID`` is set (i.e. we are inside an interactive
564
+ ``salloc`` session), runs the command synchronously via ``srun``
565
+ for fast iteration. Otherwise, generates an sbatch script,
566
+ submits it, and polls for completion.
567
+ """
568
+ # Fast path: interactive allocation detected
569
+ if os.environ.get("SLURM_JOB_ID"):
570
+ return self._run_slurm_interactive(
571
+ command=command,
572
+ container=container,
573
+ output_id=output_id,
574
+ universe_id=universe_id,
575
+ resources=resources,
576
+ external_inputs=external_inputs,
577
+ cwd=cwd,
578
+ )
579
+
580
+ scheduler = self.target_config.get("scheduler", {})
581
+ container_runtime = scheduler.get("container_runtime", "podman-hpc")
582
+
583
+ output_path = self.project_root / "results" / universe_id
584
+ output_path.mkdir(parents=True, exist_ok=True)
585
+
586
+ # Generate the sbatch script
587
+ resource_limits = self.target_config.get("resource_limits", {})
588
+ script = generate_sbatch_script(
589
+ command=command,
590
+ container=container,
591
+ container_runtime=container_runtime,
592
+ project_root=self.project_root,
593
+ output_id=output_id,
594
+ universe_id=universe_id,
595
+ resources=resources,
596
+ scheduler_config=scheduler,
597
+ resource_limits=resource_limits,
598
+ external_inputs=external_inputs,
599
+ )
600
+
601
+ # Write script to a temp file inside the project so it's on the
602
+ # shared filesystem visible to compute nodes.
603
+ scripts_dir = self.project_root / "results" / ".slurm"
604
+ scripts_dir.mkdir(parents=True, exist_ok=True)
605
+ job_name = f"{output_id}_{universe_id}"
606
+ script_path = scripts_dir / f"{job_name}.sh"
607
+ script_path.write_text(script)
608
+ script_path.chmod(0o755)
609
+
610
+ logger.info("Submitting SLURM job for %s/%s", output_id, universe_id)
611
+ logger.debug("sbatch script:\n%s", script)
612
+
613
+ # Submit via sbatch
614
+ try:
615
+ submit_result = subprocess.run(
616
+ ["sbatch", str(script_path)],
617
+ capture_output=True,
618
+ text=True,
619
+ cwd=str(self.project_root),
620
+ )
621
+ except FileNotFoundError:
622
+ return ExecutionResult(
623
+ exit_code=127,
624
+ output_path=output_path,
625
+ metadata={"stderr": "sbatch: command not found"},
626
+ )
627
+
628
+ if submit_result.returncode != 0:
629
+ return ExecutionResult(
630
+ exit_code=submit_result.returncode,
631
+ output_path=output_path,
632
+ metadata={
633
+ "stderr": submit_result.stderr,
634
+ "backend": "slurm",
635
+ },
636
+ )
637
+
638
+ # Parse job ID from "Submitted batch job 12345"
639
+ job_id = _parse_sbatch_job_id(submit_result.stdout)
640
+ if job_id is None:
641
+ return ExecutionResult(
642
+ exit_code=1,
643
+ output_path=output_path,
644
+ metadata={
645
+ "stderr": f"Could not parse job ID from: {submit_result.stdout}",
646
+ "backend": "slurm",
647
+ },
648
+ )
649
+
650
+ logger.info("SLURM job submitted: %s", job_id)
651
+
652
+ # Poll for completion
653
+ poll_config = self.target_config.get("poll", {})
654
+ poll_interval = poll_config.get("interval_seconds", 15)
655
+ poll_timeout = poll_config.get("timeout_seconds", 14400) # 4h default
656
+ exit_code, job_metadata = _poll_slurm_job(
657
+ job_id, poll_interval=poll_interval, poll_timeout=poll_timeout,
658
+ )
659
+
660
+ # Collect stdout/stderr from SLURM output files
661
+ slurm_stdout = ""
662
+ slurm_stderr = ""
663
+ stdout_file = scripts_dir / f"{job_name}.out"
664
+ stderr_file = scripts_dir / f"{job_name}.err"
665
+ if stdout_file.exists():
666
+ slurm_stdout = stdout_file.read_text()[-2000:]
667
+ if stderr_file.exists():
668
+ slurm_stderr = stderr_file.read_text()[-2000:]
669
+
670
+ return ExecutionResult(
671
+ exit_code=exit_code,
672
+ output_path=output_path,
673
+ metadata={
674
+ "backend": "slurm",
675
+ "slurm_job_id": job_id,
676
+ "container_runtime": container_runtime,
677
+ "stdout": slurm_stdout,
678
+ "stderr": slurm_stderr,
679
+ "sbatch_script": str(script_path),
680
+ **job_metadata,
681
+ },
682
+ )
683
+
684
+
685
+ # ---------------------------------------------------------------------------
686
+ # SLURM helpers
687
+ # ---------------------------------------------------------------------------
688
+
689
+
690
+ def translate_resources_to_slurm_directives(
691
+ resources: dict[str, Any],
692
+ scheduler_config: dict[str, Any] | None = None,
693
+ *,
694
+ resource_limits: dict[str, Any] | None = None,
695
+ ) -> list[str]:
696
+ """Translate ASTRA resource requirements to SLURM #SBATCH directives.
697
+
698
+ Returns a list of directive strings (without the ``#SBATCH`` prefix).
699
+
700
+ When *resource_limits* is provided (non-``None``) and no explicit
701
+ ``time_limit`` appears in *resources*, a default ``--time`` directive is
702
+ emitted using ``resource_limits["max_walltime_minutes"]`` (falling back
703
+ to 30 minutes). This ensures SLURM jobs always have a walltime.
704
+ """
705
+ scheduler_config = scheduler_config or {}
706
+ directives: list[str] = []
707
+
708
+ # Extra SLURM args from CLI passthrough (e.g. --partition, --qos, --constraint)
709
+ extra_args = scheduler_config.get("extra_slurm_args", [])
710
+
711
+ # Helper to check if a flag is already in extra args (CLI overrides target)
712
+ def _in_extra(flag: str) -> bool:
713
+ return any(a.startswith(flag) for a in extra_args)
714
+
715
+ # Extract constraint from extra args for account suffix resolution
716
+ constraint = scheduler_config.get("constraint")
717
+ for arg in extra_args:
718
+ if arg.startswith("--constraint"):
719
+ constraint = arg.split("=", 1)[1] if "=" in arg else None
720
+
721
+ account = scheduler_config.get("account")
722
+ # Apply site-specific account suffix (e.g. _g for GPU on Perlmutter)
723
+ if account and not _in_extra("--account"):
724
+ site_key = scheduler_config.get("site")
725
+ if site_key and constraint:
726
+ from lightcone.engine.site_registry import resolve_account
727
+ account = resolve_account(site_key, account, constraint)
728
+ directives.append(f"--account={account}")
729
+ if not _in_extra("--partition"):
730
+ if partition := scheduler_config.get("partition"):
731
+ directives.append(f"--partition={partition}")
732
+ if not _in_extra("--qos"):
733
+ if qos := scheduler_config.get("qos"):
734
+ directives.append(f"--qos={qos}")
735
+ if not _in_extra("--constraint"):
736
+ if constraint:
737
+ directives.append(f"--constraint={constraint}")
738
+
739
+ if nodes := resources.get("nodes"):
740
+ directives.append(f"--nodes={nodes}")
741
+ if cpus := resources.get("cpus"):
742
+ directives.append(f"--cpus-per-task={cpus}")
743
+ if memory := resources.get("memory"):
744
+ directives.append(f"--mem={memory}")
745
+ if not _in_extra("--gpus"):
746
+ if gpus := resources.get("gpus"):
747
+ directives.append(f"--gpus={gpus}")
748
+ if time_limit := resources.get("time_limit"):
749
+ directives.append(f"--time={_normalise_time_limit(time_limit)}")
750
+ elif resource_limits is not None:
751
+ # No explicit time_limit — apply a default so SLURM doesn't reject
752
+ # the job. Use the target's max_walltime_minutes, or 30 min.
753
+ default_minutes = resource_limits.get("max_walltime_minutes", 30)
754
+ logger.warning(
755
+ "No time_limit in recipe resources; defaulting to %d minutes "
756
+ "(from resource_limits.max_walltime_minutes)",
757
+ default_minutes,
758
+ )
759
+ directives.append(f"--time={_normalise_time_limit(default_minutes)}")
760
+
761
+ # Append any extra SLURM flags passed through from the CLI
762
+ directives.extend(extra_args)
763
+
764
+ return directives
765
+
766
+
767
+ def _normalise_time_limit(value: str | int) -> str:
768
+ """Convert time_limit values like '2h', '30m', 120 to HH:MM:SS."""
769
+ if isinstance(value, int):
770
+ # Assume minutes
771
+ hours, minutes = divmod(value, 60)
772
+ return f"{hours:02d}:{minutes:02d}:00"
773
+ value = str(value).strip()
774
+ match = re.match(r"^(\d+)([hm]?)$", value, re.IGNORECASE)
775
+ if match:
776
+ num, unit = int(match.group(1)), match.group(2).lower()
777
+ if unit == "h":
778
+ return f"{num:02d}:00:00"
779
+ elif unit == "m":
780
+ hours, minutes = divmod(num, 60)
781
+ return f"{hours:02d}:{minutes:02d}:00"
782
+ else:
783
+ # bare number = minutes
784
+ hours, minutes = divmod(num, 60)
785
+ return f"{hours:02d}:{minutes:02d}:00"
786
+ # Already in HH:MM:SS or similar — pass through
787
+ return value
788
+
789
+
790
+ def generate_sbatch_script(
791
+ command: str,
792
+ container: str | None,
793
+ container_runtime: str,
794
+ project_root: Path,
795
+ output_id: str,
796
+ universe_id: str,
797
+ resources: dict[str, Any],
798
+ scheduler_config: dict[str, Any] | None = None,
799
+ resource_limits: dict[str, Any] | None = None,
800
+ external_inputs: dict[str, str] | None = None,
801
+ ) -> str:
802
+ """Generate an sbatch script for a recipe execution.
803
+
804
+ Uses ``podman-hpc`` as the container runtime on Perlmutter. Containers are
805
+ run via ``podman-hpc run`` with optional ``--gpu`` / ``--mpi`` flags.
806
+ Images must be pre-migrated (``podman-hpc migrate``).
807
+
808
+ If no container image is specified, the command runs directly (no
809
+ container wrapping).
810
+ """
811
+ scheduler_config = scheduler_config or {}
812
+ job_name = f"lc_{output_id}_{universe_id}"
813
+
814
+ lines = ["#!/bin/bash"]
815
+
816
+ # Standard SBATCH header
817
+ lines.append(f"#SBATCH --job-name={job_name}")
818
+
819
+ # Output / error files go next to the script
820
+ lines.append(f"#SBATCH --output=results/.slurm/{output_id}_{universe_id}.out")
821
+ lines.append(f"#SBATCH --error=results/.slurm/{output_id}_{universe_id}.err")
822
+
823
+ # Resource directives
824
+ directives = translate_resources_to_slurm_directives(
825
+ resources, scheduler_config, resource_limits=resource_limits,
826
+ )
827
+
828
+ for d in directives:
829
+ lines.append(f"#SBATCH {d}")
830
+
831
+ lines.append("")
832
+ lines.append("# --- lightcone-cli / ASTRA recipe execution ---")
833
+ lines.append(f"cd {project_root}")
834
+ lines.append("")
835
+
836
+ # Build the execution command based on container runtime
837
+ if container and container_runtime == "podman-hpc":
838
+ lines.append(_podman_hpc_run_command(
839
+ command, container, project_root, resources, scheduler_config,
840
+ external_inputs=external_inputs,
841
+ ))
842
+ else:
843
+ # No container — symlink external inputs into data/ directory
844
+ if external_inputs:
845
+ lines.append("mkdir -p data")
846
+ for input_id, source in sorted(external_inputs.items()):
847
+ lines.append(f"ln -sfn {source} data/{input_id}")
848
+ lines.append("")
849
+ # Run directly
850
+ lines.append(command)
851
+
852
+ lines.append("")
853
+ return "\n".join(lines)
854
+
855
+
856
+ def _podman_hpc_run_command(
857
+ command: str,
858
+ container: str,
859
+ project_root: Path,
860
+ resources: dict[str, Any],
861
+ scheduler_config: dict[str, Any],
862
+ external_inputs: dict[str, str] | None = None,
863
+ ) -> str:
864
+ """Build a podman-hpc run invocation for use inside an sbatch script.
865
+
866
+ Key podman-hpc flags used:
867
+ - ``--rm``: Clean up container after exit.
868
+ - ``--gpu``: Bind NVIDIA GPU devices and drivers into the container.
869
+ - ``--mpi``: Inject Cray MPICH for optimized MPI on Slingshot.
870
+ - ``-v``: Volume mount the project root at /workspace.
871
+ - ``-w``: Set the working directory inside the container.
872
+ """
873
+ parts = ["podman-hpc", "run", "--rm"]
874
+
875
+ # GPU support
876
+ if resources.get("gpus"):
877
+ parts.append("--gpu")
878
+
879
+ # MPI support — if the scheduler config opts in
880
+ container_flags = scheduler_config.get("container_flags", [])
881
+ if "--mpi" in container_flags:
882
+ parts.append("--mpi")
883
+ if "--nccl" in container_flags:
884
+ parts.append("--nccl")
885
+ if "--cuda-mpi" in container_flags:
886
+ parts.append("--cuda-mpi")
887
+
888
+ # Any extra user-specified flags
889
+ for flag in container_flags:
890
+ if flag not in ("--mpi", "--nccl", "--cuda-mpi", "--gpu"):
891
+ parts.append(flag)
892
+
893
+ # Volume mount project root
894
+ parts.extend(["-v", f"{project_root}:/workspace", "-w", "/workspace"])
895
+
896
+ # Read-only volume mounts for external inputs
897
+ for input_id, source in sorted((external_inputs or {}).items()):
898
+ parts.extend(["-v", f"{source}:/workspace/data/{input_id}:ro"])
899
+
900
+ parts.append(container)
901
+ parts.extend(["sh", "-c", _shell_quote(command)])
902
+
903
+ return " ".join(parts)
904
+
905
+
906
+ def _shell_quote(s: str) -> str:
907
+ """Wrap a string in single quotes for shell, escaping internal quotes."""
908
+ return shlex.quote(s)
909
+
910
+
911
+ def _parse_sbatch_job_id(stdout: str) -> str | None:
912
+ """Extract job ID from sbatch output like 'Submitted batch job 12345'."""
913
+ match = re.search(r"Submitted batch job (\d+)", stdout)
914
+ return match.group(1) if match else None
915
+
916
+
917
+ def _poll_slurm_job(
918
+ job_id: str,
919
+ poll_interval: int = 15,
920
+ poll_timeout: int = 14400,
921
+ ) -> tuple[int, dict[str, Any]]:
922
+ """Poll a SLURM job until completion, returning (exit_code, metadata).
923
+
924
+ Uses ``sacct`` to query the final status. Falls back to ``squeue`` if
925
+ sacct is not available.
926
+ """
927
+ start = time.monotonic()
928
+ metadata: dict[str, Any] = {}
929
+
930
+ while True:
931
+ elapsed = time.monotonic() - start
932
+ if elapsed > poll_timeout:
933
+ logger.warning(
934
+ "SLURM job %s timed out after %ds", job_id, poll_timeout,
935
+ )
936
+ metadata["timeout"] = True
937
+ return 1, metadata
938
+
939
+ # Check sacct for completed job
940
+ exit_code, meta = _check_sacct(job_id)
941
+ if exit_code is not None:
942
+ metadata.update(meta)
943
+ return exit_code, metadata
944
+
945
+ logger.debug(
946
+ "Job %s still running (%.0fs elapsed), polling in %ds",
947
+ job_id, elapsed, poll_interval,
948
+ )
949
+ time.sleep(poll_interval)
950
+
951
+
952
+ def _check_sacct(job_id: str) -> tuple[int | None, dict[str, Any]]:
953
+ """Query sacct for a completed job. Returns (exit_code, metadata) or (None, {})."""
954
+ try:
955
+ result = subprocess.run(
956
+ [
957
+ "sacct", "-j", job_id,
958
+ "--format=JobID,State,ExitCode,Elapsed,NodeList",
959
+ "--noheader", "--parsable2",
960
+ ],
961
+ capture_output=True,
962
+ text=True,
963
+ )
964
+ except FileNotFoundError:
965
+ # sacct not available, try squeue fallback
966
+ return _check_squeue_fallback(job_id)
967
+
968
+ if result.returncode != 0:
969
+ return None, {}
970
+
971
+ for line in result.stdout.strip().splitlines():
972
+ parts = line.split("|")
973
+ if len(parts) < 5:
974
+ continue
975
+ sacct_job_id, state, exit_code_str, elapsed, nodelist = parts[:5]
976
+ # Only look at the main job step (not .batch, .extern, etc.)
977
+ if "." in sacct_job_id:
978
+ continue
979
+
980
+ state = state.strip()
981
+ if state in ("COMPLETED", "FAILED", "CANCELLED", "TIMEOUT", "NODE_FAIL",
982
+ "OUT_OF_MEMORY", "PREEMPTED"):
983
+ # For non-COMPLETED states always treat as failure — sacct often
984
+ # reports 0:0 for CANCELLED jobs which would be a false success.
985
+ if state != "COMPLETED":
986
+ exit_code = 1
987
+ else:
988
+ try:
989
+ exit_code = int(exit_code_str.split(":")[0])
990
+ except (ValueError, IndexError):
991
+ exit_code = 0
992
+ return exit_code, {
993
+ "slurm_state": state,
994
+ "elapsed": elapsed,
995
+ "nodelist": nodelist,
996
+ }
997
+
998
+ return None, {}
999
+
1000
+
1001
+ def _check_squeue_fallback(job_id: str) -> tuple[int | None, dict[str, Any]]:
1002
+ """Fallback: use squeue to check if a job is still running."""
1003
+ try:
1004
+ result = subprocess.run(
1005
+ ["squeue", "-j", job_id, "--noheader", "--format=%T"],
1006
+ capture_output=True,
1007
+ text=True,
1008
+ )
1009
+ except FileNotFoundError:
1010
+ logger.error("Neither sacct nor squeue found — cannot poll SLURM job")
1011
+ return 1, {"error": "sacct and squeue not found"}
1012
+
1013
+ state = result.stdout.strip()
1014
+ if not state:
1015
+ # Job no longer in queue — assume completed (sacct would be better)
1016
+ return 0, {"slurm_state": "COMPLETED (assumed)"}
1017
+ return None, {}