runops 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.
- runops/__init__.py +5 -0
- runops/_data/README.md +476 -0
- runops/adapters/__init__.py +29 -0
- runops/adapters/_utils/__init__.py +36 -0
- runops/adapters/_utils/toml_utils.py +81 -0
- runops/adapters/base.py +335 -0
- runops/adapters/contrib/__init__.py +5 -0
- runops/adapters/contrib/beach.py +837 -0
- runops/adapters/contrib/emses.py +1010 -0
- runops/adapters/generic.py +439 -0
- runops/adapters/registry.py +244 -0
- runops/cli/__init__.py +3 -0
- runops/cli/analyze.py +222 -0
- runops/cli/clone.py +104 -0
- runops/cli/config.py +217 -0
- runops/cli/context.py +56 -0
- runops/cli/create.py +263 -0
- runops/cli/dashboard.py +179 -0
- runops/cli/extend.py +204 -0
- runops/cli/history.py +105 -0
- runops/cli/init.py +1432 -0
- runops/cli/jobs.py +145 -0
- runops/cli/knowledge.py +1017 -0
- runops/cli/list.py +102 -0
- runops/cli/log.py +163 -0
- runops/cli/main.py +96 -0
- runops/cli/manage.py +231 -0
- runops/cli/new.py +343 -0
- runops/cli/notes.py +257 -0
- runops/cli/run_lookup.py +148 -0
- runops/cli/setup.py +174 -0
- runops/cli/status.py +187 -0
- runops/cli/submit.py +297 -0
- runops/cli/update.py +113 -0
- runops/cli/update_harness.py +245 -0
- runops/cli/update_refs.py +370 -0
- runops/core/__init__.py +3 -0
- runops/core/actions.py +1186 -0
- runops/core/analysis.py +1090 -0
- runops/core/campaign.py +156 -0
- runops/core/case.py +307 -0
- runops/core/context.py +426 -0
- runops/core/discovery.py +192 -0
- runops/core/environment.py +266 -0
- runops/core/exceptions.py +93 -0
- runops/core/knowledge.py +595 -0
- runops/core/knowledge_source.py +1204 -0
- runops/core/manifest.py +219 -0
- runops/core/project.py +171 -0
- runops/core/provenance.py +147 -0
- runops/core/retry.py +193 -0
- runops/core/run.py +170 -0
- runops/core/run_creation.py +456 -0
- runops/core/site.py +337 -0
- runops/core/state.py +197 -0
- runops/core/survey.py +380 -0
- runops/core/validation.py +40 -0
- runops/harness/__init__.py +27 -0
- runops/harness/builder.py +327 -0
- runops/harness/claude.py +189 -0
- runops/jobgen/__init__.py +3 -0
- runops/jobgen/generator.py +295 -0
- runops/launchers/__init__.py +17 -0
- runops/launchers/base.py +313 -0
- runops/launchers/mpiexec.py +131 -0
- runops/launchers/mpirun.py +132 -0
- runops/launchers/srun.py +126 -0
- runops/sites/__init__.py +0 -0
- runops/sites/camphor.md +98 -0
- runops/sites/camphor.toml +27 -0
- runops/slurm/__init__.py +3 -0
- runops/slurm/query.py +384 -0
- runops/slurm/submit.py +203 -0
- runops/templates/__init__.py +29 -0
- runops/templates/adapters/beach/agent_guide.md +50 -0
- runops/templates/adapters/beach/beach.toml +19 -0
- runops/templates/adapters/beach/case.toml +16 -0
- runops/templates/adapters/beach/summarize.py +272 -0
- runops/templates/adapters/emses/agent_guide.md +39 -0
- runops/templates/adapters/emses/case.toml +18 -0
- runops/templates/adapters/emses/plasma.toml +118 -0
- runops/templates/adapters/emses/summarize.py +413 -0
- runops/templates/adapters/generic/case.toml.j2 +13 -0
- runops/templates/adapters/generic/summarize.py +21 -0
- runops/templates/agent.md +156 -0
- runops/templates/rules/cookbook.md +22 -0
- runops/templates/scaffold/campaign.toml.j2 +10 -0
- runops/templates/scaffold/cases_claude.md +22 -0
- runops/templates/scaffold/facts.toml +2 -0
- runops/templates/scaffold/gitignore.txt +30 -0
- runops/templates/scaffold/notes/README.md +69 -0
- runops/templates/scaffold/rules/plan-before-act.md +17 -0
- runops/templates/scaffold/rules/runops-workflow.md +84 -0
- runops/templates/scaffold/rules/upstream-feedback.md +85 -0
- runops/templates/scaffold/runs_claude.md +24 -0
- runops/templates/scaffold/vscode_settings.json +9 -0
- runops/templates/skills/analyze/SKILL.md +40 -0
- runops/templates/skills/check-status/SKILL.md +29 -0
- runops/templates/skills/cleanup/SKILL.md +43 -0
- runops/templates/skills/create-run/SKILL.md +135 -0
- runops/templates/skills/debug-failed/SKILL.md +38 -0
- runops/templates/skills/learn/SKILL.md +54 -0
- runops/templates/skills/new-case/SKILL.md +108 -0
- runops/templates/skills/note/SKILL.md +107 -0
- runops/templates/skills/run-all/SKILL.md +47 -0
- runops/templates/skills/runops-reference/SKILL.md +203 -0
- runops/templates/skills/setup-campaign/SKILL.md +111 -0
- runops/templates/skills/setup-env/SKILL.md +32 -0
- runops/templates/skills/survey-design/SKILL.md +73 -0
- runops/templates/survey.toml.j2 +22 -0
- runops-0.2.0.dist-info/METADATA +491 -0
- runops-0.2.0.dist-info/RECORD +115 -0
- runops-0.2.0.dist-info/WHEEL +4 -0
- runops-0.2.0.dist-info/entry_points.txt +2 -0
- runops-0.2.0.dist-info/licenses/LICENSE +201 -0
runops/cli/init.py
ADDED
|
@@ -0,0 +1,1432 @@
|
|
|
1
|
+
"""CLI commands for project initialization and environment checks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib.resources
|
|
6
|
+
import logging
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Annotated, Any, Optional
|
|
13
|
+
|
|
14
|
+
import typer
|
|
15
|
+
|
|
16
|
+
from runops.core.discovery import validate_uniqueness
|
|
17
|
+
from runops.core.exceptions import DuplicateRunIdError, ProjectConfigError
|
|
18
|
+
from runops.core.project import load_project
|
|
19
|
+
from runops.harness.builder import _collect_doc_repos, _collect_pip_packages
|
|
20
|
+
|
|
21
|
+
if sys.version_info >= (3, 11):
|
|
22
|
+
import tomllib
|
|
23
|
+
else:
|
|
24
|
+
import tomli as tomllib
|
|
25
|
+
|
|
26
|
+
try:
|
|
27
|
+
import tomli_w
|
|
28
|
+
except ImportError:
|
|
29
|
+
tomli_w = None # type: ignore[assignment]
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
_SIMPROJECT_FILE = "runops.toml"
|
|
34
|
+
_SIMULATORS_FILE = "simulators.toml"
|
|
35
|
+
_LAUNCHERS_FILE = "launchers.toml"
|
|
36
|
+
_CAMPAIGN_FILE = "campaign.toml"
|
|
37
|
+
_CLAUDE_MD = "CLAUDE.md"
|
|
38
|
+
_AGENTS_MD = "AGENTS.md"
|
|
39
|
+
_SKILLS_DIR = ".claude/skills"
|
|
40
|
+
_RULES_DIR = ".claude/rules"
|
|
41
|
+
_CLAUDE_SETTINGS = ".claude/settings.json"
|
|
42
|
+
_VSCODE_DIR = ".vscode"
|
|
43
|
+
_VSCODE_SETTINGS = "settings.json"
|
|
44
|
+
|
|
45
|
+
_SCHEMA_BASE_URL = "https://raw.githubusercontent.com/Nkzono99/runops/main/schemas"
|
|
46
|
+
_DEFAULT_SIMCTL_REPO = "https://github.com/Nkzono99/runops.git"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _safe_echo(message: str, *, err: bool = False) -> None:
|
|
50
|
+
"""Echo text even when the console encoding cannot represent it."""
|
|
51
|
+
try:
|
|
52
|
+
typer.echo(message, err=err)
|
|
53
|
+
except UnicodeEncodeError:
|
|
54
|
+
stream = sys.stderr if err else sys.stdout
|
|
55
|
+
encoding = getattr(stream, "encoding", None) or "utf-8"
|
|
56
|
+
safe_message = message.encode(encoding, errors="replace").decode(
|
|
57
|
+
encoding,
|
|
58
|
+
errors="replace",
|
|
59
|
+
)
|
|
60
|
+
typer.echo(safe_message, err=err)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _write_if_missing(path: Path, content: str) -> bool:
|
|
64
|
+
"""Write content to path if the file does not already exist.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
path: File path to create.
|
|
68
|
+
content: File content to write.
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
True if the file was created, False if it already existed.
|
|
72
|
+
"""
|
|
73
|
+
if path.exists():
|
|
74
|
+
return False
|
|
75
|
+
path.write_text(content, encoding="utf-8")
|
|
76
|
+
return True
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _mkdir_if_missing(path: Path) -> bool:
|
|
80
|
+
"""Create a directory if it does not already exist.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
path: Directory path to create.
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
True if the directory was created, False if it already existed.
|
|
87
|
+
"""
|
|
88
|
+
if path.exists():
|
|
89
|
+
return False
|
|
90
|
+
path.mkdir(parents=True)
|
|
91
|
+
return True
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _create_runops_skeleton(project_dir: Path, created: list[str]) -> None:
|
|
95
|
+
"""Create .runops/ skeleton (insights/, facts.toml, knowledge/).
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
project_dir: Project root directory.
|
|
99
|
+
created: Mutable list to append created items.
|
|
100
|
+
"""
|
|
101
|
+
runops_dir = project_dir / ".runops"
|
|
102
|
+
if _mkdir_if_missing(runops_dir):
|
|
103
|
+
created.append(".runops/")
|
|
104
|
+
if _mkdir_if_missing(runops_dir / "insights"):
|
|
105
|
+
created.append(".runops/insights/")
|
|
106
|
+
from runops.templates import load_static
|
|
107
|
+
|
|
108
|
+
if _write_if_missing(runops_dir / "facts.toml", load_static("scaffold/facts.toml")):
|
|
109
|
+
created.append(".runops/facts.toml")
|
|
110
|
+
# Knowledge integration directories
|
|
111
|
+
if _mkdir_if_missing(runops_dir / "knowledge"):
|
|
112
|
+
created.append(".runops/knowledge/")
|
|
113
|
+
if _mkdir_if_missing(runops_dir / "knowledge" / "enabled"):
|
|
114
|
+
created.append(".runops/knowledge/enabled/")
|
|
115
|
+
if _mkdir_if_missing(runops_dir / "knowledge" / "candidates"):
|
|
116
|
+
created.append(".runops/knowledge/candidates/")
|
|
117
|
+
if _mkdir_if_missing(runops_dir / "knowledge" / "candidates" / "facts"):
|
|
118
|
+
created.append(".runops/knowledge/candidates/facts/")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _create_notes_skeleton(project_dir: Path, created: list[str]) -> None:
|
|
122
|
+
"""Create the lab-notebook skeleton (``notes/`` + README + ``reports/``).
|
|
123
|
+
|
|
124
|
+
The lab notebook lives next to ``.runops/insights/`` but serves a
|
|
125
|
+
different purpose: chronological, append-only entries, edited via
|
|
126
|
+
``runops notes append`` or the ``/note`` skill.
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
project_dir: Project root directory.
|
|
130
|
+
created: Mutable list to append created items.
|
|
131
|
+
"""
|
|
132
|
+
notes_dir = project_dir / "notes"
|
|
133
|
+
if _mkdir_if_missing(notes_dir):
|
|
134
|
+
created.append("notes/")
|
|
135
|
+
if _mkdir_if_missing(notes_dir / "reports"):
|
|
136
|
+
created.append("notes/reports/")
|
|
137
|
+
|
|
138
|
+
from runops.templates import load_static
|
|
139
|
+
|
|
140
|
+
readme_path = notes_dir / "README.md"
|
|
141
|
+
if _write_if_missing(readme_path, load_static("scaffold/notes/README.md")):
|
|
142
|
+
created.append("notes/README.md")
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _build_simulators_toml(simulator_names: list[str]) -> str:
|
|
146
|
+
"""Build simulators.toml content from adapter default configs.
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
simulator_names: List of simulator adapter names (e.g. ["emses", "beach"]).
|
|
150
|
+
|
|
151
|
+
Returns:
|
|
152
|
+
TOML string for simulators.toml.
|
|
153
|
+
|
|
154
|
+
Raises:
|
|
155
|
+
typer.BadParameter: If a simulator name is not recognized.
|
|
156
|
+
"""
|
|
157
|
+
# Ensure built-in adapters are registered
|
|
158
|
+
import runops.adapters # noqa: F401
|
|
159
|
+
from runops.adapters.registry import get_global_registry
|
|
160
|
+
|
|
161
|
+
registry = get_global_registry()
|
|
162
|
+
available = registry.list_adapters()
|
|
163
|
+
|
|
164
|
+
config: dict[str, Any] = {"simulators": {}}
|
|
165
|
+
for sim_name in simulator_names:
|
|
166
|
+
if sim_name not in available:
|
|
167
|
+
msg = f"Unknown simulator: '{sim_name}'. Available: {', '.join(available)}"
|
|
168
|
+
raise typer.BadParameter(msg)
|
|
169
|
+
adapter_cls = registry.get(sim_name)
|
|
170
|
+
config["simulators"][sim_name] = adapter_cls.default_config()
|
|
171
|
+
|
|
172
|
+
if tomli_w is None:
|
|
173
|
+
# Fallback to manual TOML generation
|
|
174
|
+
lines = ["[simulators]", ""]
|
|
175
|
+
for sim_name, sim_cfg in config["simulators"].items():
|
|
176
|
+
lines.append(f"[simulators.{sim_name}]")
|
|
177
|
+
for key, value in sim_cfg.items():
|
|
178
|
+
if isinstance(value, list):
|
|
179
|
+
items = ", ".join(f'"{v}"' for v in value)
|
|
180
|
+
lines.append(f"{key} = [{items}]")
|
|
181
|
+
elif isinstance(value, str):
|
|
182
|
+
lines.append(f'{key} = "{value}"')
|
|
183
|
+
else:
|
|
184
|
+
lines.append(f"{key} = {value}")
|
|
185
|
+
lines.append("")
|
|
186
|
+
return "\n".join(lines) + "\n"
|
|
187
|
+
|
|
188
|
+
import io
|
|
189
|
+
|
|
190
|
+
buf = io.BytesIO()
|
|
191
|
+
tomli_w.dump(config, buf)
|
|
192
|
+
return buf.getvalue().decode("utf-8")
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _clone_doc_repos(
|
|
196
|
+
project_dir: Path, simulator_names: list[str]
|
|
197
|
+
) -> tuple[list[str], list[str]]:
|
|
198
|
+
"""Clone documentation repos into project_dir/refs/.
|
|
199
|
+
|
|
200
|
+
Returns:
|
|
201
|
+
Tuple of (created_list, skipped_list).
|
|
202
|
+
"""
|
|
203
|
+
repos = _collect_doc_repos(simulator_names)
|
|
204
|
+
if not repos:
|
|
205
|
+
return [], []
|
|
206
|
+
|
|
207
|
+
created: list[str] = []
|
|
208
|
+
skipped: list[str] = []
|
|
209
|
+
refs_dir = project_dir / "refs"
|
|
210
|
+
refs_dir.mkdir(exist_ok=True)
|
|
211
|
+
|
|
212
|
+
for url, dest in repos:
|
|
213
|
+
dest_path = refs_dir / dest
|
|
214
|
+
rel = f"refs/{dest}"
|
|
215
|
+
if dest_path.exists():
|
|
216
|
+
skipped.append(rel)
|
|
217
|
+
continue
|
|
218
|
+
result = subprocess.run(
|
|
219
|
+
["git", "clone", "--depth", "1", url, str(dest_path)],
|
|
220
|
+
capture_output=True,
|
|
221
|
+
text=True,
|
|
222
|
+
encoding="utf-8",
|
|
223
|
+
errors="replace",
|
|
224
|
+
check=False,
|
|
225
|
+
)
|
|
226
|
+
if result.returncode == 0:
|
|
227
|
+
created.append(rel)
|
|
228
|
+
else:
|
|
229
|
+
logger.warning(
|
|
230
|
+
"git clone %s failed: %s", url, (result.stderr or "").strip()
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
return created, skipped
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _discover_agent_docs(
|
|
237
|
+
project_dir: Path, doc_repos: list[tuple[str, str]]
|
|
238
|
+
) -> list[str]:
|
|
239
|
+
"""Discover manifest-declared agent doc imports from cloned repos.
|
|
240
|
+
|
|
241
|
+
Returns:
|
|
242
|
+
List of relative import paths rooted at the project directory.
|
|
243
|
+
"""
|
|
244
|
+
from runops.core.knowledge_source import discover_repo_imports
|
|
245
|
+
|
|
246
|
+
refs_dir = project_dir / "refs"
|
|
247
|
+
paths: list[str] = []
|
|
248
|
+
for _url, dest in doc_repos:
|
|
249
|
+
repo_root = refs_dir / dest
|
|
250
|
+
if not repo_root.is_dir():
|
|
251
|
+
continue
|
|
252
|
+
for rel_path in discover_repo_imports(repo_root):
|
|
253
|
+
paths.append(f"refs/{dest}/{rel_path}".replace("\\", "/"))
|
|
254
|
+
|
|
255
|
+
runops_root = project_dir / "tools" / "runops"
|
|
256
|
+
if runops_root.is_dir():
|
|
257
|
+
for rel_path in discover_repo_imports(runops_root):
|
|
258
|
+
paths.append(f"tools/runops/{rel_path}".replace("\\", "/"))
|
|
259
|
+
return paths
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _prepare_knowledge_imports(
|
|
263
|
+
project_dir: Path,
|
|
264
|
+
simulator_names: list[str],
|
|
265
|
+
*,
|
|
266
|
+
sync_sources: bool = False,
|
|
267
|
+
validate_sources: bool = False,
|
|
268
|
+
) -> str:
|
|
269
|
+
"""Sync knowledge sources and render the unified imports.md bundle."""
|
|
270
|
+
from runops.core.knowledge_source import (
|
|
271
|
+
KnowledgeConfig,
|
|
272
|
+
load_knowledge_config,
|
|
273
|
+
render_imports,
|
|
274
|
+
sync_all_sources,
|
|
275
|
+
validate_source_structure,
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
knowledge_imports_path = ""
|
|
279
|
+
config = load_knowledge_config(project_dir)
|
|
280
|
+
|
|
281
|
+
if sync_sources and config is not None and config.sources:
|
|
282
|
+
typer.echo("Syncing knowledge sources...")
|
|
283
|
+
for name, status in sync_all_sources(project_dir, config):
|
|
284
|
+
typer.echo(f" {name}: {status}")
|
|
285
|
+
|
|
286
|
+
if validate_sources:
|
|
287
|
+
for source in config.sources:
|
|
288
|
+
if not source.mount:
|
|
289
|
+
continue
|
|
290
|
+
source_path = project_dir / source.mount
|
|
291
|
+
if not source_path.is_dir():
|
|
292
|
+
continue
|
|
293
|
+
issues = validate_source_structure(source_path)
|
|
294
|
+
for issue in issues:
|
|
295
|
+
typer.echo(f" Warning ({source.name}): {issue}")
|
|
296
|
+
|
|
297
|
+
doc_repos = _collect_doc_repos(simulator_names) if simulator_names else []
|
|
298
|
+
agent_doc_imports = _discover_agent_docs(project_dir, doc_repos)
|
|
299
|
+
|
|
300
|
+
render_config = config if config is not None else KnowledgeConfig()
|
|
301
|
+
should_render = bool(agent_doc_imports or (config is not None and config.sources))
|
|
302
|
+
if should_render:
|
|
303
|
+
render_imports(
|
|
304
|
+
project_dir,
|
|
305
|
+
render_config,
|
|
306
|
+
extra_imports=agent_doc_imports or None,
|
|
307
|
+
)
|
|
308
|
+
typer.echo(" Rendered knowledge imports")
|
|
309
|
+
|
|
310
|
+
if render_config.generate_claude_imports:
|
|
311
|
+
imports_file = (
|
|
312
|
+
project_dir / render_config.derived_dir / "enabled" / "imports.md"
|
|
313
|
+
)
|
|
314
|
+
if imports_file.is_file():
|
|
315
|
+
knowledge_imports_path = f"{render_config.derived_dir}/enabled/imports.md"
|
|
316
|
+
|
|
317
|
+
return knowledge_imports_path
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _get_data_path() -> Path:
|
|
321
|
+
"""Return the path to the package's bundled _data directory.
|
|
322
|
+
|
|
323
|
+
Falls back to the repository root when running in editable/dev mode
|
|
324
|
+
where force-include has not been applied.
|
|
325
|
+
"""
|
|
326
|
+
pkg_data = Path(str(importlib.resources.files("runops") / "_data"))
|
|
327
|
+
if (pkg_data / "README.md").is_file():
|
|
328
|
+
return pkg_data
|
|
329
|
+
# Dev mode fallback: walk up from this file to the repo root
|
|
330
|
+
repo_root = Path(__file__).resolve().parent.parent.parent.parent
|
|
331
|
+
if (repo_root / "README.md").is_file() and (repo_root / "docs").is_dir():
|
|
332
|
+
return repo_root
|
|
333
|
+
return pkg_data
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _copy_docs(project_dir: Path) -> tuple[list[str], list[str]]:
|
|
337
|
+
"""Copy bundled README.md and docs/ into the project directory.
|
|
338
|
+
|
|
339
|
+
Returns:
|
|
340
|
+
Tuple of (created_list, skipped_list).
|
|
341
|
+
"""
|
|
342
|
+
created: list[str] = []
|
|
343
|
+
skipped: list[str] = []
|
|
344
|
+
data_path = _get_data_path()
|
|
345
|
+
|
|
346
|
+
# README.md -> docs/runops-guide.md
|
|
347
|
+
readme_src = data_path / "README.md"
|
|
348
|
+
readme_dst = project_dir / "docs" / "runops-guide.md"
|
|
349
|
+
if readme_dst.exists():
|
|
350
|
+
skipped.append("docs/runops-guide.md")
|
|
351
|
+
elif readme_src.exists():
|
|
352
|
+
readme_dst.parent.mkdir(exist_ok=True)
|
|
353
|
+
shutil.copy2(readme_src, readme_dst)
|
|
354
|
+
created.append("docs/runops-guide.md")
|
|
355
|
+
|
|
356
|
+
# docs/*.md
|
|
357
|
+
docs_src = data_path / "docs"
|
|
358
|
+
if docs_src.is_dir():
|
|
359
|
+
docs_dst = project_dir / "docs"
|
|
360
|
+
docs_dst.mkdir(exist_ok=True)
|
|
361
|
+
for src_file in sorted(docs_src.iterdir()):
|
|
362
|
+
if src_file.suffix == ".md":
|
|
363
|
+
dst_file = docs_dst / src_file.name
|
|
364
|
+
rel = f"docs/{src_file.name}"
|
|
365
|
+
if dst_file.exists():
|
|
366
|
+
skipped.append(rel)
|
|
367
|
+
else:
|
|
368
|
+
shutil.copy2(src_file, dst_file)
|
|
369
|
+
created.append(rel)
|
|
370
|
+
|
|
371
|
+
return created, skipped
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _search_knowledge_repos() -> list[tuple[str, str]]:
|
|
375
|
+
"""Search GitHub for shared knowledge repos using ``gh``.
|
|
376
|
+
|
|
377
|
+
Looks for repos matching ``*shared_knowledge*`` in the
|
|
378
|
+
authenticated user's repositories.
|
|
379
|
+
|
|
380
|
+
Returns:
|
|
381
|
+
List of (full_name, clone_url) tuples.
|
|
382
|
+
"""
|
|
383
|
+
try:
|
|
384
|
+
result = subprocess.run(
|
|
385
|
+
[
|
|
386
|
+
"gh",
|
|
387
|
+
"repo",
|
|
388
|
+
"list",
|
|
389
|
+
"--limit",
|
|
390
|
+
"50",
|
|
391
|
+
"--json",
|
|
392
|
+
"nameWithOwner,sshUrl,description",
|
|
393
|
+
],
|
|
394
|
+
capture_output=True,
|
|
395
|
+
text=True,
|
|
396
|
+
encoding="utf-8",
|
|
397
|
+
errors="replace",
|
|
398
|
+
check=False,
|
|
399
|
+
)
|
|
400
|
+
except FileNotFoundError:
|
|
401
|
+
return []
|
|
402
|
+
if result.returncode != 0:
|
|
403
|
+
return []
|
|
404
|
+
|
|
405
|
+
import json
|
|
406
|
+
|
|
407
|
+
try:
|
|
408
|
+
repos = json.loads(result.stdout)
|
|
409
|
+
except (json.JSONDecodeError, TypeError):
|
|
410
|
+
return []
|
|
411
|
+
|
|
412
|
+
candidates: list[tuple[str, str]] = []
|
|
413
|
+
for repo in repos:
|
|
414
|
+
name = repo.get("nameWithOwner", "")
|
|
415
|
+
# Match *shared_knowledge* or *shared-knowledge* (case-insensitive)
|
|
416
|
+
repo_name = name.rsplit("/", 1)[-1] if "/" in name else name
|
|
417
|
+
lower = repo_name.lower()
|
|
418
|
+
if "shared_knowledge" in lower or "shared-knowledge" in lower:
|
|
419
|
+
ssh_url = repo.get("sshUrl", "")
|
|
420
|
+
if ssh_url:
|
|
421
|
+
candidates.append((name, ssh_url))
|
|
422
|
+
|
|
423
|
+
return candidates
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def _prompt_knowledge_sources(
|
|
427
|
+
project_dir: Path,
|
|
428
|
+
) -> list[Any]:
|
|
429
|
+
"""Interactively prompt the user to attach knowledge sources.
|
|
430
|
+
|
|
431
|
+
Searches GitHub for repos matching ``*shared_knowledge*`` or
|
|
432
|
+
``*shared-knowledge*`` first, then presents them as candidates.
|
|
433
|
+
Also allows manual URL entry.
|
|
434
|
+
|
|
435
|
+
Returns:
|
|
436
|
+
List of KnowledgeSource to attach.
|
|
437
|
+
"""
|
|
438
|
+
from runops.core.knowledge_source import KnowledgeSource
|
|
439
|
+
|
|
440
|
+
# Search first, then ask
|
|
441
|
+
typer.echo("\n Searching GitHub for shared knowledge repos...")
|
|
442
|
+
candidates = _search_knowledge_repos()
|
|
443
|
+
|
|
444
|
+
selected_sources: list[KnowledgeSource] = []
|
|
445
|
+
|
|
446
|
+
if candidates:
|
|
447
|
+
typer.echo("\n Found knowledge repositories:")
|
|
448
|
+
for i, (full_name, _url) in enumerate(candidates, 1):
|
|
449
|
+
typer.echo(f" {i}. {full_name}")
|
|
450
|
+
|
|
451
|
+
selection = typer.prompt(
|
|
452
|
+
"\n Select repos to attach (comma-separated numbers, Enter to skip)",
|
|
453
|
+
default="",
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
for token in selection.split(","):
|
|
457
|
+
token = token.strip()
|
|
458
|
+
if not token:
|
|
459
|
+
continue
|
|
460
|
+
if token.isdigit():
|
|
461
|
+
idx = int(token) - 1
|
|
462
|
+
if 0 <= idx < len(candidates):
|
|
463
|
+
full_name, url = candidates[idx]
|
|
464
|
+
repo_name = full_name.rsplit("/", 1)[-1]
|
|
465
|
+
selected_sources.append(
|
|
466
|
+
KnowledgeSource(
|
|
467
|
+
name=repo_name,
|
|
468
|
+
source_type="git",
|
|
469
|
+
url=url,
|
|
470
|
+
ref="main",
|
|
471
|
+
mount=f"refs/knowledge/{repo_name}",
|
|
472
|
+
)
|
|
473
|
+
)
|
|
474
|
+
else:
|
|
475
|
+
typer.echo(f" Warning: ignoring invalid number '{token}'")
|
|
476
|
+
else:
|
|
477
|
+
typer.echo(" No shared knowledge repos found on GitHub.")
|
|
478
|
+
|
|
479
|
+
# Allow manual entry
|
|
480
|
+
while True:
|
|
481
|
+
manual = typer.prompt(
|
|
482
|
+
"\n Add a knowledge source manually? "
|
|
483
|
+
"(git URL, local path, or Enter to finish)",
|
|
484
|
+
default="",
|
|
485
|
+
)
|
|
486
|
+
if not manual.strip():
|
|
487
|
+
break
|
|
488
|
+
|
|
489
|
+
manual = manual.strip()
|
|
490
|
+
# Detect type
|
|
491
|
+
is_git = (
|
|
492
|
+
manual.startswith("https://")
|
|
493
|
+
or manual.startswith("http://")
|
|
494
|
+
or manual.startswith("git@")
|
|
495
|
+
)
|
|
496
|
+
if is_git:
|
|
497
|
+
# Extract name from URL
|
|
498
|
+
stem = manual.rsplit("/", 1)[-1].rsplit(":", 1)[-1]
|
|
499
|
+
if stem.endswith(".git"):
|
|
500
|
+
stem = stem[:-4]
|
|
501
|
+
source_name = typer.prompt(" Source name", default=stem)
|
|
502
|
+
selected_sources.append(
|
|
503
|
+
KnowledgeSource(
|
|
504
|
+
name=source_name,
|
|
505
|
+
source_type="git",
|
|
506
|
+
url=manual,
|
|
507
|
+
ref="main",
|
|
508
|
+
mount=f"refs/knowledge/{source_name}",
|
|
509
|
+
)
|
|
510
|
+
)
|
|
511
|
+
else:
|
|
512
|
+
# Local path
|
|
513
|
+
p = Path(manual).expanduser()
|
|
514
|
+
source_name = typer.prompt(" Source name", default=p.name)
|
|
515
|
+
selected_sources.append(
|
|
516
|
+
KnowledgeSource(
|
|
517
|
+
name=source_name,
|
|
518
|
+
source_type="path",
|
|
519
|
+
url=manual,
|
|
520
|
+
mount=f"refs/knowledge/{source_name}",
|
|
521
|
+
)
|
|
522
|
+
)
|
|
523
|
+
typer.echo(f" Added: {source_name}")
|
|
524
|
+
|
|
525
|
+
if selected_sources:
|
|
526
|
+
typer.echo(f"\n {len(selected_sources)} knowledge source(s) selected.")
|
|
527
|
+
return selected_sources
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def _prompt_simulators() -> tuple[list[str], dict[str, dict[str, Any]]]:
|
|
531
|
+
"""Interactively prompt the user to select and configure simulators.
|
|
532
|
+
|
|
533
|
+
Returns:
|
|
534
|
+
Tuple of (simulator_names, {name: config_dict}).
|
|
535
|
+
"""
|
|
536
|
+
import runops.adapters # noqa: F401
|
|
537
|
+
from runops.adapters.registry import get_global_registry
|
|
538
|
+
|
|
539
|
+
registry = get_global_registry()
|
|
540
|
+
available = registry.list_adapters()
|
|
541
|
+
|
|
542
|
+
typer.echo("\nAvailable simulators:")
|
|
543
|
+
for i, name in enumerate(available, 1):
|
|
544
|
+
typer.echo(f" {i}. {name}")
|
|
545
|
+
|
|
546
|
+
selection = typer.prompt(
|
|
547
|
+
"\nSelect simulators (comma-separated numbers or names, Enter to skip)",
|
|
548
|
+
default="",
|
|
549
|
+
)
|
|
550
|
+
|
|
551
|
+
if not selection.strip():
|
|
552
|
+
return [], {}
|
|
553
|
+
|
|
554
|
+
# Parse selection — accept both numbers and names
|
|
555
|
+
selected: list[str] = []
|
|
556
|
+
for token in selection.split(","):
|
|
557
|
+
token = token.strip()
|
|
558
|
+
if not token:
|
|
559
|
+
continue
|
|
560
|
+
if token.isdigit():
|
|
561
|
+
idx = int(token) - 1
|
|
562
|
+
if 0 <= idx < len(available):
|
|
563
|
+
selected.append(available[idx])
|
|
564
|
+
else:
|
|
565
|
+
typer.echo(f" Warning: ignoring invalid number '{token}'")
|
|
566
|
+
elif token in available:
|
|
567
|
+
selected.append(token)
|
|
568
|
+
else:
|
|
569
|
+
typer.echo(f" Warning: unknown simulator '{token}', skipping")
|
|
570
|
+
|
|
571
|
+
if not selected:
|
|
572
|
+
return [], {}
|
|
573
|
+
|
|
574
|
+
# Interactive config for each selected simulator
|
|
575
|
+
use_interactive = typer.confirm("\nCustomize simulator settings?", default=False)
|
|
576
|
+
|
|
577
|
+
configs: dict[str, dict[str, Any]] = {}
|
|
578
|
+
for sim_name in selected:
|
|
579
|
+
adapter_cls = registry.get(sim_name)
|
|
580
|
+
if use_interactive:
|
|
581
|
+
configs[sim_name] = adapter_cls.interactive_config()
|
|
582
|
+
else:
|
|
583
|
+
configs[sim_name] = adapter_cls.default_config()
|
|
584
|
+
|
|
585
|
+
return selected, configs
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
@dataclass
|
|
589
|
+
class _BundledSiteProfile:
|
|
590
|
+
"""A bundled site profile loaded from sites/*.toml.
|
|
591
|
+
|
|
592
|
+
Used during ``runops init`` to offer preconfigured site choices.
|
|
593
|
+
The file uses the same ``[site]`` format as project-level ``site.toml``,
|
|
594
|
+
plus an optional ``[launcher]`` section for launcher defaults.
|
|
595
|
+
|
|
596
|
+
Attributes:
|
|
597
|
+
name: Site name (file stem, e.g. "camphor").
|
|
598
|
+
launcher: Launcher-only configuration dict for launchers.toml.
|
|
599
|
+
source_path: Path to the bundled .toml file (copied as site.toml).
|
|
600
|
+
docs_path: Path to companion .md documentation (may not exist).
|
|
601
|
+
"""
|
|
602
|
+
|
|
603
|
+
name: str
|
|
604
|
+
launcher: dict[str, Any]
|
|
605
|
+
source_path: Path
|
|
606
|
+
docs_path: Path | None = None
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
# Legacy alias for backward compatibility with code that references the old name.
|
|
610
|
+
SiteProfile = _BundledSiteProfile
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
def _load_site_profiles() -> dict[str, _BundledSiteProfile]:
|
|
614
|
+
"""Load site profiles from bundled TOML files in runops/sites/.
|
|
615
|
+
|
|
616
|
+
Each file uses the unified format:
|
|
617
|
+
- ``[site]`` section → copied as-is to project ``site.toml``
|
|
618
|
+
- ``[launcher]`` section → used for ``launchers.toml`` defaults
|
|
619
|
+
"""
|
|
620
|
+
sites_dir = Path(__file__).resolve().parent.parent / "sites"
|
|
621
|
+
profiles: dict[str, _BundledSiteProfile] = {}
|
|
622
|
+
if not sites_dir.is_dir():
|
|
623
|
+
return profiles
|
|
624
|
+
for toml_file in sorted(sites_dir.glob("*.toml")):
|
|
625
|
+
with open(toml_file, "rb") as f:
|
|
626
|
+
data = tomllib.load(f)
|
|
627
|
+
# Require at least a [site] or [launcher] section
|
|
628
|
+
if "site" not in data and "launcher" not in data:
|
|
629
|
+
continue
|
|
630
|
+
launcher_data = dict(data.get("launcher", {}))
|
|
631
|
+
docs_file = toml_file.with_suffix(".md")
|
|
632
|
+
profiles[toml_file.stem] = _BundledSiteProfile(
|
|
633
|
+
name=toml_file.stem,
|
|
634
|
+
launcher=launcher_data,
|
|
635
|
+
source_path=toml_file,
|
|
636
|
+
docs_path=docs_file if docs_file.is_file() else None,
|
|
637
|
+
)
|
|
638
|
+
return profiles
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
def _prompt_launchers() -> tuple[dict[str, dict[str, Any]], _BundledSiteProfile | None]:
|
|
642
|
+
"""Interactively prompt for launcher configuration.
|
|
643
|
+
|
|
644
|
+
Returns:
|
|
645
|
+
Tuple of (launcher config dict, selected _BundledSiteProfile or None).
|
|
646
|
+
"""
|
|
647
|
+
site_profiles = _load_site_profiles()
|
|
648
|
+
|
|
649
|
+
typer.echo("\nLauncher configuration:")
|
|
650
|
+
typer.echo(" Site profiles (preconfigured):")
|
|
651
|
+
site_names = list(site_profiles.keys())
|
|
652
|
+
for i, sname in enumerate(site_names, start=1):
|
|
653
|
+
typer.echo(f" {i}. {sname}")
|
|
654
|
+
offset = len(site_names)
|
|
655
|
+
typer.echo(" Launcher types:")
|
|
656
|
+
typer.echo(f" {offset + 1}. srun (Slurm)")
|
|
657
|
+
typer.echo(f" {offset + 2}. mpirun (OpenMPI)")
|
|
658
|
+
typer.echo(f" {offset + 3}. mpiexec (MPICH)")
|
|
659
|
+
|
|
660
|
+
selection = typer.prompt(
|
|
661
|
+
"\nSelect site profile or launcher type (number or name, Enter to skip)",
|
|
662
|
+
default="",
|
|
663
|
+
)
|
|
664
|
+
|
|
665
|
+
sel = selection.strip()
|
|
666
|
+
if not sel:
|
|
667
|
+
return {}, None
|
|
668
|
+
|
|
669
|
+
# Check site profiles first
|
|
670
|
+
site_map = {str(i): name for i, name in enumerate(site_names, start=1)}
|
|
671
|
+
if sel in site_map:
|
|
672
|
+
profile_name = site_map[sel]
|
|
673
|
+
profile = site_profiles[profile_name]
|
|
674
|
+
return {profile_name: dict(profile.launcher)}, profile
|
|
675
|
+
if sel in site_profiles:
|
|
676
|
+
profile = site_profiles[sel]
|
|
677
|
+
return {sel: dict(profile.launcher)}, profile
|
|
678
|
+
|
|
679
|
+
# Launcher types
|
|
680
|
+
launcher_map = {
|
|
681
|
+
str(offset + 1): "srun",
|
|
682
|
+
str(offset + 2): "mpirun",
|
|
683
|
+
str(offset + 3): "mpiexec",
|
|
684
|
+
}
|
|
685
|
+
launcher_type = launcher_map.get(sel, sel)
|
|
686
|
+
|
|
687
|
+
if launcher_type not in ("srun", "mpirun", "mpiexec"):
|
|
688
|
+
typer.echo(f" Unknown selection '{sel}', skipping")
|
|
689
|
+
return {}, None
|
|
690
|
+
|
|
691
|
+
launcher_name = typer.prompt(" Launcher profile name", default=launcher_type)
|
|
692
|
+
|
|
693
|
+
config: dict[str, Any] = {"type": launcher_type}
|
|
694
|
+
|
|
695
|
+
if launcher_type == "srun":
|
|
696
|
+
use_slurm = typer.confirm(
|
|
697
|
+
" Use SLURM_NTASKS (rely on #SBATCH --ntasks)?", default=True
|
|
698
|
+
)
|
|
699
|
+
config["use_slurm_ntasks"] = use_slurm
|
|
700
|
+
config["args"] = typer.prompt(
|
|
701
|
+
" Extra srun arguments (e.g. --mpi=pmix)", default=""
|
|
702
|
+
)
|
|
703
|
+
elif launcher_type in ("mpirun", "mpiexec"):
|
|
704
|
+
config["args"] = typer.prompt(f" Extra {launcher_type} arguments", default="")
|
|
705
|
+
|
|
706
|
+
# Module loading
|
|
707
|
+
modules_str = typer.prompt(
|
|
708
|
+
" Modules to load (space-separated, Enter to skip)", default=""
|
|
709
|
+
)
|
|
710
|
+
if modules_str.strip():
|
|
711
|
+
config["modules"] = modules_str.strip().split()
|
|
712
|
+
|
|
713
|
+
# Clean empty args
|
|
714
|
+
if not config.get("args"):
|
|
715
|
+
config.pop("args", None)
|
|
716
|
+
|
|
717
|
+
return {launcher_name: config}, None
|
|
718
|
+
|
|
719
|
+
|
|
720
|
+
def _build_simulators_toml_from_configs(
|
|
721
|
+
configs: dict[str, dict[str, Any]],
|
|
722
|
+
) -> str:
|
|
723
|
+
"""Serialize simulator configs to TOML string."""
|
|
724
|
+
full_config: dict[str, Any] = {"simulators": configs}
|
|
725
|
+
|
|
726
|
+
if tomli_w is None:
|
|
727
|
+
lines = ["[simulators]", ""]
|
|
728
|
+
for sim_name, sim_cfg in configs.items():
|
|
729
|
+
lines.append(f"[simulators.{sim_name}]")
|
|
730
|
+
for key, value in sim_cfg.items():
|
|
731
|
+
if isinstance(value, list):
|
|
732
|
+
items = ", ".join(f'"{v}"' for v in value)
|
|
733
|
+
lines.append(f"{key} = [{items}]")
|
|
734
|
+
elif isinstance(value, str):
|
|
735
|
+
lines.append(f'{key} = "{value}"')
|
|
736
|
+
else:
|
|
737
|
+
lines.append(f"{key} = {value}")
|
|
738
|
+
lines.append("")
|
|
739
|
+
return "\n".join(lines) + "\n"
|
|
740
|
+
|
|
741
|
+
import io
|
|
742
|
+
|
|
743
|
+
buf = io.BytesIO()
|
|
744
|
+
tomli_w.dump(full_config, buf)
|
|
745
|
+
return buf.getvalue().decode("utf-8")
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
def _build_launchers_toml(launchers: dict[str, dict[str, Any]]) -> str:
|
|
749
|
+
"""Serialize launcher configs to TOML string."""
|
|
750
|
+
if not launchers:
|
|
751
|
+
return "[launchers]\n"
|
|
752
|
+
|
|
753
|
+
full_config: dict[str, Any] = {"launchers": launchers}
|
|
754
|
+
|
|
755
|
+
if tomli_w is None:
|
|
756
|
+
lines = ["[launchers]", ""]
|
|
757
|
+
for name, cfg in launchers.items():
|
|
758
|
+
lines.append(f"[launchers.{name}]")
|
|
759
|
+
for key, value in cfg.items():
|
|
760
|
+
if isinstance(value, list):
|
|
761
|
+
items = ", ".join(f'"{v}"' for v in value)
|
|
762
|
+
lines.append(f"{key} = [{items}]")
|
|
763
|
+
elif isinstance(value, str):
|
|
764
|
+
lines.append(f'{key} = "{value}"')
|
|
765
|
+
elif isinstance(value, bool):
|
|
766
|
+
lines.append(f"{key} = {str(value).lower()}")
|
|
767
|
+
else:
|
|
768
|
+
lines.append(f"{key} = {value}")
|
|
769
|
+
lines.append("")
|
|
770
|
+
return "\n".join(lines) + "\n"
|
|
771
|
+
|
|
772
|
+
import io
|
|
773
|
+
|
|
774
|
+
buf = io.BytesIO()
|
|
775
|
+
tomli_w.dump(full_config, buf)
|
|
776
|
+
return buf.getvalue().decode("utf-8")
|
|
777
|
+
|
|
778
|
+
|
|
779
|
+
def _build_campaign_toml(project_name: str, simulator_names: list[str]) -> str:
|
|
780
|
+
"""Build a minimal campaign.toml skeleton."""
|
|
781
|
+
from runops.templates import render
|
|
782
|
+
|
|
783
|
+
return render(
|
|
784
|
+
"scaffold/campaign.toml.j2",
|
|
785
|
+
schema_base_url=_SCHEMA_BASE_URL,
|
|
786
|
+
project_name=project_name,
|
|
787
|
+
simulator=simulator_names[0] if simulator_names else "",
|
|
788
|
+
)
|
|
789
|
+
|
|
790
|
+
|
|
791
|
+
def _venv_pip_executable(venv_dir: Path) -> Path:
|
|
792
|
+
"""Return the pip executable path inside a virtual environment."""
|
|
793
|
+
if sys.platform == "win32":
|
|
794
|
+
return venv_dir / "Scripts" / "pip.exe"
|
|
795
|
+
return venv_dir / "bin" / "pip"
|
|
796
|
+
|
|
797
|
+
|
|
798
|
+
def _find_uv() -> str:
|
|
799
|
+
"""Find the uv executable, falling back to 'uv'."""
|
|
800
|
+
uv_path = shutil.which("uv")
|
|
801
|
+
return uv_path if uv_path else "uv"
|
|
802
|
+
|
|
803
|
+
|
|
804
|
+
def _bootstrap_environment(
|
|
805
|
+
project_dir: Path,
|
|
806
|
+
sim_names: list[str],
|
|
807
|
+
runops_repo: str,
|
|
808
|
+
created: list[str],
|
|
809
|
+
skipped: list[str],
|
|
810
|
+
) -> None:
|
|
811
|
+
"""Bootstrap .venv, clone runops into tools/, and editable-install.
|
|
812
|
+
|
|
813
|
+
Args:
|
|
814
|
+
project_dir: Project root directory.
|
|
815
|
+
sim_names: List of simulator names for pip packages.
|
|
816
|
+
runops_repo: Git URL for runops repository.
|
|
817
|
+
created: Mutable list to append created items.
|
|
818
|
+
skipped: Mutable list to append skipped items.
|
|
819
|
+
"""
|
|
820
|
+
uv = _find_uv()
|
|
821
|
+
venv_dir = project_dir / ".venv"
|
|
822
|
+
tools_dir = project_dir / "tools"
|
|
823
|
+
runops_dir = tools_dir / "runops"
|
|
824
|
+
|
|
825
|
+
# 1. Create .venv via uv
|
|
826
|
+
if venv_dir.exists():
|
|
827
|
+
skipped.append(".venv")
|
|
828
|
+
else:
|
|
829
|
+
typer.echo(" Creating .venv ...")
|
|
830
|
+
venv_result = subprocess.run(
|
|
831
|
+
[uv, "venv", str(venv_dir)],
|
|
832
|
+
cwd=str(project_dir),
|
|
833
|
+
capture_output=True,
|
|
834
|
+
text=True,
|
|
835
|
+
encoding="utf-8",
|
|
836
|
+
errors="replace",
|
|
837
|
+
check=False,
|
|
838
|
+
)
|
|
839
|
+
if venv_result.returncode == 0:
|
|
840
|
+
created.append(".venv")
|
|
841
|
+
else:
|
|
842
|
+
typer.echo(
|
|
843
|
+
f" Warning: uv venv failed: {(venv_result.stderr or '').strip()}"
|
|
844
|
+
)
|
|
845
|
+
return
|
|
846
|
+
|
|
847
|
+
# 2. Clone runops into tools/
|
|
848
|
+
if runops_dir.exists():
|
|
849
|
+
skipped.append("tools/runops")
|
|
850
|
+
else:
|
|
851
|
+
typer.echo(" Cloning runops into tools/ ...")
|
|
852
|
+
tools_dir.mkdir(exist_ok=True)
|
|
853
|
+
clone_result = subprocess.run(
|
|
854
|
+
["git", "clone", runops_repo, str(runops_dir)],
|
|
855
|
+
capture_output=True,
|
|
856
|
+
text=True,
|
|
857
|
+
encoding="utf-8",
|
|
858
|
+
errors="replace",
|
|
859
|
+
check=False,
|
|
860
|
+
)
|
|
861
|
+
if clone_result.returncode == 0:
|
|
862
|
+
created.append("tools/runops")
|
|
863
|
+
else:
|
|
864
|
+
typer.echo(
|
|
865
|
+
f" Warning: git clone failed: "
|
|
866
|
+
f"{(clone_result.stderr or '').strip()[:300]}"
|
|
867
|
+
)
|
|
868
|
+
return
|
|
869
|
+
|
|
870
|
+
# 3. Editable install runops into .venv
|
|
871
|
+
typer.echo(" Installing runops (editable) ...")
|
|
872
|
+
install_result = subprocess.run(
|
|
873
|
+
[
|
|
874
|
+
uv,
|
|
875
|
+
"pip",
|
|
876
|
+
"install",
|
|
877
|
+
"-e",
|
|
878
|
+
str(runops_dir),
|
|
879
|
+
"--python",
|
|
880
|
+
str(
|
|
881
|
+
venv_dir
|
|
882
|
+
/ ("Scripts/python.exe" if sys.platform == "win32" else "bin/python")
|
|
883
|
+
),
|
|
884
|
+
],
|
|
885
|
+
cwd=str(project_dir),
|
|
886
|
+
capture_output=True,
|
|
887
|
+
text=True,
|
|
888
|
+
encoding="utf-8",
|
|
889
|
+
errors="replace",
|
|
890
|
+
check=False,
|
|
891
|
+
)
|
|
892
|
+
if install_result.returncode == 0:
|
|
893
|
+
created.append("uv pip install -e tools/runops")
|
|
894
|
+
else:
|
|
895
|
+
typer.echo(
|
|
896
|
+
f" Warning: editable install failed:\n"
|
|
897
|
+
f" {(install_result.stderr or '').strip()[:300]}"
|
|
898
|
+
)
|
|
899
|
+
|
|
900
|
+
# 4. Install simulator-specific packages
|
|
901
|
+
pip_pkgs = _collect_pip_packages(sim_names) if sim_names else []
|
|
902
|
+
if pip_pkgs:
|
|
903
|
+
typer.echo(f" Installing: {', '.join(pip_pkgs)} ...")
|
|
904
|
+
pkg_result = subprocess.run(
|
|
905
|
+
[
|
|
906
|
+
uv,
|
|
907
|
+
"pip",
|
|
908
|
+
"install",
|
|
909
|
+
*pip_pkgs,
|
|
910
|
+
"--python",
|
|
911
|
+
str(
|
|
912
|
+
venv_dir
|
|
913
|
+
/ (
|
|
914
|
+
"Scripts/python.exe"
|
|
915
|
+
if sys.platform == "win32"
|
|
916
|
+
else "bin/python"
|
|
917
|
+
)
|
|
918
|
+
),
|
|
919
|
+
],
|
|
920
|
+
cwd=str(project_dir),
|
|
921
|
+
capture_output=True,
|
|
922
|
+
text=True,
|
|
923
|
+
encoding="utf-8",
|
|
924
|
+
errors="replace",
|
|
925
|
+
check=False,
|
|
926
|
+
)
|
|
927
|
+
if pkg_result.returncode == 0:
|
|
928
|
+
created.append(f"pip install ({len(pip_pkgs)} packages)")
|
|
929
|
+
else:
|
|
930
|
+
_safe_echo(
|
|
931
|
+
f" Warning: pip install failed:\n"
|
|
932
|
+
f" {(pkg_result.stderr or '').strip()[:300]}",
|
|
933
|
+
)
|
|
934
|
+
|
|
935
|
+
# 5. Activation hint
|
|
936
|
+
if sys.platform == "win32":
|
|
937
|
+
activate_cmd = r".venv\Scripts\activate"
|
|
938
|
+
else:
|
|
939
|
+
activate_cmd = "source .venv/bin/activate"
|
|
940
|
+
typer.echo(f"\n Next: {activate_cmd}")
|
|
941
|
+
typer.echo(" Then: runops doctor")
|
|
942
|
+
|
|
943
|
+
|
|
944
|
+
def init(
|
|
945
|
+
simulators: Annotated[
|
|
946
|
+
Optional[list[str]],
|
|
947
|
+
typer.Argument(help="Simulator names to configure (e.g. emses beach)."),
|
|
948
|
+
] = None,
|
|
949
|
+
path: Annotated[
|
|
950
|
+
Optional[Path],
|
|
951
|
+
typer.Option("--path", "-p", help="Directory to initialize (defaults to cwd)."),
|
|
952
|
+
] = None,
|
|
953
|
+
name: Annotated[
|
|
954
|
+
Optional[str],
|
|
955
|
+
typer.Option("--name", "-n", help="Project name (defaults to directory name)."),
|
|
956
|
+
] = None,
|
|
957
|
+
yes: Annotated[
|
|
958
|
+
bool,
|
|
959
|
+
typer.Option("--yes", "-y", help="Skip interactive prompts, use defaults."),
|
|
960
|
+
] = False,
|
|
961
|
+
no_upstream_feedback: Annotated[
|
|
962
|
+
bool,
|
|
963
|
+
typer.Option(
|
|
964
|
+
"--no-upstream-feedback",
|
|
965
|
+
help="Do not include the upstream-feedback rule for the AI agent.",
|
|
966
|
+
),
|
|
967
|
+
] = False,
|
|
968
|
+
runops_repo: Annotated[
|
|
969
|
+
str,
|
|
970
|
+
typer.Option(
|
|
971
|
+
"--runops-repo",
|
|
972
|
+
help="Git URL for runops repository.",
|
|
973
|
+
),
|
|
974
|
+
] = _DEFAULT_SIMCTL_REPO,
|
|
975
|
+
) -> None:
|
|
976
|
+
"""Initialize a new runops project (runops.toml etc.).
|
|
977
|
+
|
|
978
|
+
By default, runs in interactive mode with guided prompts.
|
|
979
|
+
Use --yes / -y to skip prompts and use defaults.
|
|
980
|
+
|
|
981
|
+
Simulator names can also be passed directly:
|
|
982
|
+
runops init emses beach
|
|
983
|
+
|
|
984
|
+
Bootstrap usage (no prior install needed):
|
|
985
|
+
uvx --from runops runops init
|
|
986
|
+
"""
|
|
987
|
+
interactive = not yes
|
|
988
|
+
project_dir = (path or Path.cwd()).resolve()
|
|
989
|
+
|
|
990
|
+
if not project_dir.exists():
|
|
991
|
+
project_dir.mkdir(parents=True)
|
|
992
|
+
|
|
993
|
+
# Interactive project name
|
|
994
|
+
if interactive and not name:
|
|
995
|
+
project_name = typer.prompt("Project name", default=project_dir.name)
|
|
996
|
+
else:
|
|
997
|
+
project_name = name or project_dir.name
|
|
998
|
+
|
|
999
|
+
created: list[str] = []
|
|
1000
|
+
skipped: list[str] = []
|
|
1001
|
+
|
|
1002
|
+
upstream_feedback = not no_upstream_feedback
|
|
1003
|
+
|
|
1004
|
+
# runops.toml
|
|
1005
|
+
harness_line = (
|
|
1006
|
+
f"\n[harness]\nupstream_feedback = {'true' if upstream_feedback else 'false'}\n"
|
|
1007
|
+
)
|
|
1008
|
+
simproject_content = (
|
|
1009
|
+
f"#:schema {_SCHEMA_BASE_URL}/simproject.json\n"
|
|
1010
|
+
f'[project]\nname = "{project_name}"\ndescription = ""\n' + harness_line
|
|
1011
|
+
)
|
|
1012
|
+
if _write_if_missing(project_dir / _SIMPROJECT_FILE, simproject_content):
|
|
1013
|
+
created.append(_SIMPROJECT_FILE)
|
|
1014
|
+
else:
|
|
1015
|
+
skipped.append(_SIMPROJECT_FILE)
|
|
1016
|
+
|
|
1017
|
+
# simulators.toml
|
|
1018
|
+
sim_configs: dict[str, dict[str, Any]] = {}
|
|
1019
|
+
sim_names: list[str] = []
|
|
1020
|
+
|
|
1021
|
+
if simulators:
|
|
1022
|
+
sim_names = simulators
|
|
1023
|
+
sim_content = _build_simulators_toml(simulators)
|
|
1024
|
+
elif interactive:
|
|
1025
|
+
sim_names, sim_configs = _prompt_simulators()
|
|
1026
|
+
if sim_configs:
|
|
1027
|
+
sim_content = _build_simulators_toml_from_configs(sim_configs)
|
|
1028
|
+
else:
|
|
1029
|
+
sim_content = "[simulators]\n"
|
|
1030
|
+
else:
|
|
1031
|
+
sim_content = "[simulators]\n"
|
|
1032
|
+
|
|
1033
|
+
sim_schema = f"#:schema {_SCHEMA_BASE_URL}/simulators.json\n"
|
|
1034
|
+
sim_content = sim_schema + sim_content
|
|
1035
|
+
if _write_if_missing(project_dir / _SIMULATORS_FILE, sim_content):
|
|
1036
|
+
created.append(_SIMULATORS_FILE)
|
|
1037
|
+
else:
|
|
1038
|
+
skipped.append(_SIMULATORS_FILE)
|
|
1039
|
+
|
|
1040
|
+
# launchers.toml
|
|
1041
|
+
site_profile: _BundledSiteProfile | None = None
|
|
1042
|
+
if interactive:
|
|
1043
|
+
launcher_configs, site_profile = _prompt_launchers()
|
|
1044
|
+
launcher_content = _build_launchers_toml(launcher_configs)
|
|
1045
|
+
else:
|
|
1046
|
+
launcher_configs = {
|
|
1047
|
+
"srun": {"type": "srun", "use_slurm_ntasks": True},
|
|
1048
|
+
}
|
|
1049
|
+
launcher_content = _build_launchers_toml(launcher_configs)
|
|
1050
|
+
|
|
1051
|
+
launcher_schema = f"#:schema {_SCHEMA_BASE_URL}/launchers.json\n"
|
|
1052
|
+
launcher_content = launcher_schema + launcher_content
|
|
1053
|
+
if _write_if_missing(project_dir / _LAUNCHERS_FILE, launcher_content):
|
|
1054
|
+
created.append(_LAUNCHERS_FILE)
|
|
1055
|
+
else:
|
|
1056
|
+
skipped.append(_LAUNCHERS_FILE)
|
|
1057
|
+
|
|
1058
|
+
# site.toml — copy from bundled site profile
|
|
1059
|
+
if site_profile:
|
|
1060
|
+
from runops.core.site import _load_site_toml
|
|
1061
|
+
|
|
1062
|
+
site_file = project_dir / "site.toml"
|
|
1063
|
+
if not site_file.exists():
|
|
1064
|
+
# Read bundled file, write only the [site] sections (strip [launcher])
|
|
1065
|
+
with open(site_profile.source_path, "rb") as f:
|
|
1066
|
+
bundled_data = tomllib.load(f)
|
|
1067
|
+
site_only: dict[str, Any] = {}
|
|
1068
|
+
if "site" in bundled_data:
|
|
1069
|
+
site_only["site"] = bundled_data["site"]
|
|
1070
|
+
if site_only and tomli_w is not None:
|
|
1071
|
+
with open(site_file, "wb") as f:
|
|
1072
|
+
tomli_w.dump(site_only, f)
|
|
1073
|
+
created.append("site.toml")
|
|
1074
|
+
elif site_only:
|
|
1075
|
+
skipped.append("site.toml (tomli_w not available)")
|
|
1076
|
+
else:
|
|
1077
|
+
skipped.append("site.toml")
|
|
1078
|
+
|
|
1079
|
+
# Apply per-simulator modules from site profile to simulators.toml
|
|
1080
|
+
site_data_loaded = _load_site_toml(site_profile.source_path)
|
|
1081
|
+
if site_data_loaded.simulator_modules:
|
|
1082
|
+
sim_file = project_dir / _SIMULATORS_FILE
|
|
1083
|
+
if sim_file.exists():
|
|
1084
|
+
with open(sim_file, "rb") as f:
|
|
1085
|
+
existing = tomllib.load(f)
|
|
1086
|
+
sims = existing.get("simulators", {})
|
|
1087
|
+
updated = False
|
|
1088
|
+
for (
|
|
1089
|
+
sim_name,
|
|
1090
|
+
site_modules,
|
|
1091
|
+
) in site_data_loaded.simulator_modules.items():
|
|
1092
|
+
if sim_name in sims and site_modules:
|
|
1093
|
+
sims[sim_name]["modules"] = site_modules
|
|
1094
|
+
updated = True
|
|
1095
|
+
if updated and tomli_w is not None:
|
|
1096
|
+
existing["simulators"] = sims
|
|
1097
|
+
with open(sim_file, "wb") as f:
|
|
1098
|
+
tomli_w.dump(existing, f)
|
|
1099
|
+
|
|
1100
|
+
# SITE.md — copy companion docs from bundled site profile
|
|
1101
|
+
if site_profile and site_profile.docs_path:
|
|
1102
|
+
site_md = project_dir / "SITE.md"
|
|
1103
|
+
if site_md.exists():
|
|
1104
|
+
skipped.append("SITE.md")
|
|
1105
|
+
else:
|
|
1106
|
+
shutil.copy2(site_profile.docs_path, site_md)
|
|
1107
|
+
created.append("SITE.md")
|
|
1108
|
+
|
|
1109
|
+
# campaign.toml
|
|
1110
|
+
campaign_content = _build_campaign_toml(project_name, sim_names)
|
|
1111
|
+
if _write_if_missing(project_dir / _CAMPAIGN_FILE, campaign_content):
|
|
1112
|
+
created.append(_CAMPAIGN_FILE)
|
|
1113
|
+
else:
|
|
1114
|
+
skipped.append(_CAMPAIGN_FILE)
|
|
1115
|
+
|
|
1116
|
+
# cases/ directory (with per-simulator subdirectories)
|
|
1117
|
+
if _mkdir_if_missing(project_dir / "cases"):
|
|
1118
|
+
created.append("cases/")
|
|
1119
|
+
else:
|
|
1120
|
+
skipped.append("cases/")
|
|
1121
|
+
for sim in sim_names:
|
|
1122
|
+
sim_cases_dir = project_dir / "cases" / sim
|
|
1123
|
+
if _mkdir_if_missing(sim_cases_dir):
|
|
1124
|
+
created.append(f"cases/{sim}/")
|
|
1125
|
+
|
|
1126
|
+
# runs/ directory
|
|
1127
|
+
if _mkdir_if_missing(project_dir / "runs"):
|
|
1128
|
+
created.append("runs/")
|
|
1129
|
+
else:
|
|
1130
|
+
skipped.append("runs/")
|
|
1131
|
+
|
|
1132
|
+
# .runops/ skeleton (insights, facts, generated knowledge)
|
|
1133
|
+
_create_runops_skeleton(project_dir, created)
|
|
1134
|
+
|
|
1135
|
+
# notes/ skeleton (chronological lab notebook + reports)
|
|
1136
|
+
_create_notes_skeleton(project_dir, created)
|
|
1137
|
+
|
|
1138
|
+
# refs/ — clone simulator doc repos
|
|
1139
|
+
if sim_names:
|
|
1140
|
+
refs_created, refs_skipped = _clone_doc_repos(project_dir, sim_names)
|
|
1141
|
+
created.extend(refs_created)
|
|
1142
|
+
skipped.extend(refs_skipped)
|
|
1143
|
+
|
|
1144
|
+
# .gitignore
|
|
1145
|
+
from runops.templates import load_static
|
|
1146
|
+
|
|
1147
|
+
if _write_if_missing(
|
|
1148
|
+
project_dir / ".gitignore", load_static("scaffold/gitignore.txt")
|
|
1149
|
+
):
|
|
1150
|
+
created.append(".gitignore")
|
|
1151
|
+
else:
|
|
1152
|
+
skipped.append(".gitignore")
|
|
1153
|
+
|
|
1154
|
+
# Interactive knowledge source selection
|
|
1155
|
+
if interactive:
|
|
1156
|
+
knowledge_sources = _prompt_knowledge_sources(project_dir)
|
|
1157
|
+
if knowledge_sources:
|
|
1158
|
+
from runops.core.knowledge_source import save_knowledge_source
|
|
1159
|
+
|
|
1160
|
+
for ks in knowledge_sources:
|
|
1161
|
+
save_knowledge_source(project_dir, ks)
|
|
1162
|
+
|
|
1163
|
+
# Bootstrap: .venv + tools/runops + editable install
|
|
1164
|
+
_bootstrap_environment(project_dir, sim_names, runops_repo, created, skipped)
|
|
1165
|
+
|
|
1166
|
+
# Discover agent docs after bootstrap so tools/runops/docs/ can be imported.
|
|
1167
|
+
knowledge_imports_path = _prepare_knowledge_imports(
|
|
1168
|
+
project_dir,
|
|
1169
|
+
sim_names,
|
|
1170
|
+
sync_sources=True,
|
|
1171
|
+
)
|
|
1172
|
+
|
|
1173
|
+
# Build all harness files (CLAUDE.md, AGENTS.md, skills, rules,
|
|
1174
|
+
# settings.json, subdirectory CLAUDE.md) via the shared builder so that
|
|
1175
|
+
# `runops update-harness` can re-render the same set later.
|
|
1176
|
+
from runops.harness.builder import build_harness_bundle, save_harness_lock
|
|
1177
|
+
|
|
1178
|
+
harness = build_harness_bundle(
|
|
1179
|
+
project_name,
|
|
1180
|
+
sim_names,
|
|
1181
|
+
upstream_feedback=upstream_feedback,
|
|
1182
|
+
knowledge_imports_path=knowledge_imports_path,
|
|
1183
|
+
)
|
|
1184
|
+
for rel_path, content in sorted(harness.files.items()):
|
|
1185
|
+
full_path = project_dir / rel_path
|
|
1186
|
+
full_path.parent.mkdir(parents=True, exist_ok=True)
|
|
1187
|
+
if _write_if_missing(full_path, content):
|
|
1188
|
+
created.append(rel_path)
|
|
1189
|
+
else:
|
|
1190
|
+
skipped.append(rel_path)
|
|
1191
|
+
|
|
1192
|
+
# Persist template hashes so update-harness can detect user edits.
|
|
1193
|
+
save_harness_lock(project_dir, harness.hashes())
|
|
1194
|
+
|
|
1195
|
+
# .vscode/settings.json
|
|
1196
|
+
vscode_dir = project_dir / _VSCODE_DIR
|
|
1197
|
+
vscode_settings = vscode_dir / _VSCODE_SETTINGS
|
|
1198
|
+
if vscode_settings.exists():
|
|
1199
|
+
skipped.append(f"{_VSCODE_DIR}/{_VSCODE_SETTINGS}")
|
|
1200
|
+
else:
|
|
1201
|
+
vscode_dir.mkdir(exist_ok=True)
|
|
1202
|
+
vscode_settings.write_text(
|
|
1203
|
+
load_static("scaffold/vscode_settings.json"), encoding="utf-8"
|
|
1204
|
+
)
|
|
1205
|
+
created.append(f"{_VSCODE_DIR}/{_VSCODE_SETTINGS}")
|
|
1206
|
+
|
|
1207
|
+
# git init
|
|
1208
|
+
fresh_git = False
|
|
1209
|
+
if (project_dir / ".git").exists():
|
|
1210
|
+
skipped.append("git init")
|
|
1211
|
+
else:
|
|
1212
|
+
result = subprocess.run(
|
|
1213
|
+
["git", "init"],
|
|
1214
|
+
cwd=project_dir,
|
|
1215
|
+
capture_output=True,
|
|
1216
|
+
text=True,
|
|
1217
|
+
encoding="utf-8",
|
|
1218
|
+
errors="replace",
|
|
1219
|
+
check=False,
|
|
1220
|
+
)
|
|
1221
|
+
if result.returncode == 0:
|
|
1222
|
+
created.append("git init")
|
|
1223
|
+
fresh_git = True
|
|
1224
|
+
else:
|
|
1225
|
+
typer.echo(f" Warning: git init failed: {(result.stderr or '').strip()}")
|
|
1226
|
+
|
|
1227
|
+
# Initial commit (only for freshly created repos)
|
|
1228
|
+
if fresh_git:
|
|
1229
|
+
subprocess.run(
|
|
1230
|
+
["git", "add", "."],
|
|
1231
|
+
cwd=project_dir,
|
|
1232
|
+
capture_output=True,
|
|
1233
|
+
check=False,
|
|
1234
|
+
)
|
|
1235
|
+
result = subprocess.run(
|
|
1236
|
+
["git", "commit", "-m", "Initial commit"],
|
|
1237
|
+
cwd=project_dir,
|
|
1238
|
+
capture_output=True,
|
|
1239
|
+
text=True,
|
|
1240
|
+
encoding="utf-8",
|
|
1241
|
+
errors="replace",
|
|
1242
|
+
check=False,
|
|
1243
|
+
)
|
|
1244
|
+
if result.returncode == 0:
|
|
1245
|
+
created.append("git commit (Initial commit)")
|
|
1246
|
+
else:
|
|
1247
|
+
typer.echo(
|
|
1248
|
+
f" Warning: initial commit failed: {(result.stderr or '').strip()}"
|
|
1249
|
+
)
|
|
1250
|
+
|
|
1251
|
+
# Print results
|
|
1252
|
+
typer.echo(f"Initialized project '{project_name}' in {project_dir}")
|
|
1253
|
+
if created:
|
|
1254
|
+
typer.echo(" Created:")
|
|
1255
|
+
for item in created:
|
|
1256
|
+
typer.echo(f" {item}")
|
|
1257
|
+
if skipped:
|
|
1258
|
+
typer.echo(" Skipped (already exist):")
|
|
1259
|
+
for item in skipped:
|
|
1260
|
+
typer.echo(f" {item}")
|
|
1261
|
+
|
|
1262
|
+
|
|
1263
|
+
def doctor(
|
|
1264
|
+
path: Annotated[
|
|
1265
|
+
Optional[Path],
|
|
1266
|
+
typer.Argument(help="Project directory to check."),
|
|
1267
|
+
] = None,
|
|
1268
|
+
) -> None:
|
|
1269
|
+
"""Check the environment and project configuration for issues."""
|
|
1270
|
+
project_dir = (path or Path.cwd()).resolve()
|
|
1271
|
+
failures: list[str] = []
|
|
1272
|
+
|
|
1273
|
+
# Check runops.toml exists and is valid
|
|
1274
|
+
simproject_path = project_dir / _SIMPROJECT_FILE
|
|
1275
|
+
if not simproject_path.exists():
|
|
1276
|
+
typer.echo("[FAIL] runops.toml not found")
|
|
1277
|
+
failures.append(_SIMPROJECT_FILE)
|
|
1278
|
+
else:
|
|
1279
|
+
try:
|
|
1280
|
+
load_project(project_dir)
|
|
1281
|
+
typer.echo("[PASS] runops.toml is valid")
|
|
1282
|
+
except ProjectConfigError as e:
|
|
1283
|
+
typer.echo(f"[FAIL] runops.toml: {e}")
|
|
1284
|
+
failures.append(_SIMPROJECT_FILE)
|
|
1285
|
+
|
|
1286
|
+
# Check simulators.toml exists
|
|
1287
|
+
if (project_dir / _SIMULATORS_FILE).exists():
|
|
1288
|
+
typer.echo("[PASS] simulators.toml found")
|
|
1289
|
+
else:
|
|
1290
|
+
typer.echo("[FAIL] simulators.toml not found")
|
|
1291
|
+
failures.append(_SIMULATORS_FILE)
|
|
1292
|
+
|
|
1293
|
+
# Check launchers.toml exists
|
|
1294
|
+
if (project_dir / _LAUNCHERS_FILE).exists():
|
|
1295
|
+
typer.echo("[PASS] launchers.toml found")
|
|
1296
|
+
else:
|
|
1297
|
+
typer.echo("[FAIL] launchers.toml not found")
|
|
1298
|
+
failures.append(_LAUNCHERS_FILE)
|
|
1299
|
+
|
|
1300
|
+
# Check sbatch availability
|
|
1301
|
+
if shutil.which("sbatch") is not None:
|
|
1302
|
+
typer.echo("[PASS] sbatch is available")
|
|
1303
|
+
else:
|
|
1304
|
+
typer.echo("[FAIL] sbatch not found in PATH")
|
|
1305
|
+
failures.append("sbatch")
|
|
1306
|
+
|
|
1307
|
+
# Check simulator adapters from simulators.toml
|
|
1308
|
+
simulators_path = project_dir / _SIMULATORS_FILE
|
|
1309
|
+
if simulators_path.exists():
|
|
1310
|
+
try:
|
|
1311
|
+
with open(simulators_path, "rb") as f:
|
|
1312
|
+
sim_data = tomllib.load(f)
|
|
1313
|
+
simulators: dict[str, Any] = sim_data.get("simulators", {})
|
|
1314
|
+
if simulators:
|
|
1315
|
+
from runops.adapters.registry import AdapterRegistry
|
|
1316
|
+
|
|
1317
|
+
registry = AdapterRegistry()
|
|
1318
|
+
for sim_name, sim_cfg in simulators.items():
|
|
1319
|
+
if not isinstance(sim_cfg, dict):
|
|
1320
|
+
continue
|
|
1321
|
+
adapter_name = sim_cfg.get("adapter", "")
|
|
1322
|
+
if not adapter_name:
|
|
1323
|
+
continue
|
|
1324
|
+
try:
|
|
1325
|
+
registry.load_from_config({"simulators": {sim_name: sim_cfg}})
|
|
1326
|
+
typer.echo(
|
|
1327
|
+
f"[PASS] Simulator adapter '{adapter_name}' "
|
|
1328
|
+
f"for '{sim_name}' is importable"
|
|
1329
|
+
)
|
|
1330
|
+
except Exception as e:
|
|
1331
|
+
typer.echo(
|
|
1332
|
+
f"[FAIL] Simulator adapter '{adapter_name}' "
|
|
1333
|
+
f"for '{sim_name}': {e}"
|
|
1334
|
+
)
|
|
1335
|
+
failures.append(f"adapter:{adapter_name}")
|
|
1336
|
+
except tomllib.TOMLDecodeError as e:
|
|
1337
|
+
typer.echo(f"[FAIL] simulators.toml parse error: {e}")
|
|
1338
|
+
failures.append(_SIMULATORS_FILE)
|
|
1339
|
+
|
|
1340
|
+
# Check launcher configs from launchers.toml
|
|
1341
|
+
launchers_path = project_dir / _LAUNCHERS_FILE
|
|
1342
|
+
if launchers_path.exists():
|
|
1343
|
+
try:
|
|
1344
|
+
with open(launchers_path, "rb") as f:
|
|
1345
|
+
launcher_data = tomllib.load(f)
|
|
1346
|
+
launchers: dict[str, Any] = launcher_data.get("launchers", {})
|
|
1347
|
+
if launchers:
|
|
1348
|
+
from runops.launchers.base import Launcher, LauncherConfigError
|
|
1349
|
+
|
|
1350
|
+
for lname, lcfg in launchers.items():
|
|
1351
|
+
if not isinstance(lcfg, dict):
|
|
1352
|
+
continue
|
|
1353
|
+
try:
|
|
1354
|
+
Launcher.from_config(lname, lcfg)
|
|
1355
|
+
typer.echo(f"[PASS] Launcher profile '{lname}' is valid")
|
|
1356
|
+
except LauncherConfigError as e:
|
|
1357
|
+
typer.echo(f"[FAIL] Launcher profile '{lname}': {e}")
|
|
1358
|
+
failures.append(f"launcher:{lname}")
|
|
1359
|
+
except tomllib.TOMLDecodeError as e:
|
|
1360
|
+
typer.echo(f"[FAIL] launchers.toml parse error: {e}")
|
|
1361
|
+
failures.append(_LAUNCHERS_FILE)
|
|
1362
|
+
|
|
1363
|
+
# Check run_id uniqueness
|
|
1364
|
+
runs_dir = project_dir / "runs"
|
|
1365
|
+
if runs_dir.is_dir():
|
|
1366
|
+
try:
|
|
1367
|
+
validate_uniqueness(runs_dir)
|
|
1368
|
+
typer.echo("[PASS] No duplicate run_ids")
|
|
1369
|
+
except DuplicateRunIdError as e:
|
|
1370
|
+
typer.echo(f"[FAIL] Duplicate run_id: {e}")
|
|
1371
|
+
failures.append("run_id uniqueness")
|
|
1372
|
+
else:
|
|
1373
|
+
typer.echo("[PASS] No runs/ directory (nothing to check)")
|
|
1374
|
+
|
|
1375
|
+
# Environment detection
|
|
1376
|
+
typer.echo("\n--- Environment ---")
|
|
1377
|
+
try:
|
|
1378
|
+
from runops.core.environment import (
|
|
1379
|
+
detect_environment,
|
|
1380
|
+
load_environment,
|
|
1381
|
+
save_environment,
|
|
1382
|
+
)
|
|
1383
|
+
|
|
1384
|
+
existing = load_environment(project_dir)
|
|
1385
|
+
if existing:
|
|
1386
|
+
typer.echo(
|
|
1387
|
+
f"[PASS] environment.toml found (cluster: {existing.cluster_name})"
|
|
1388
|
+
)
|
|
1389
|
+
if existing.partitions:
|
|
1390
|
+
for p in existing.partitions:
|
|
1391
|
+
default_mark = " (default)" if p.default else ""
|
|
1392
|
+
typer.echo(f" partition: {p.name}{default_mark}")
|
|
1393
|
+
else:
|
|
1394
|
+
typer.echo("[INFO] Detecting environment...")
|
|
1395
|
+
env_info = detect_environment()
|
|
1396
|
+
if env_info.partitions:
|
|
1397
|
+
typer.echo(
|
|
1398
|
+
f" Detected {len(env_info.partitions)} Slurm partition(s)"
|
|
1399
|
+
)
|
|
1400
|
+
try:
|
|
1401
|
+
env_path = save_environment(project_dir, env_info)
|
|
1402
|
+
typer.echo(
|
|
1403
|
+
f"[PASS] Saved environment to {env_path.relative_to(project_dir)}"
|
|
1404
|
+
)
|
|
1405
|
+
except RuntimeError:
|
|
1406
|
+
typer.echo(
|
|
1407
|
+
"[WARN] Could not save environment.toml (tomli_w not installed)"
|
|
1408
|
+
)
|
|
1409
|
+
except Exception as e:
|
|
1410
|
+
typer.echo(f"[WARN] Environment detection failed: {e}")
|
|
1411
|
+
|
|
1412
|
+
# Campaign check
|
|
1413
|
+
campaign_file = project_dir / "campaign.toml"
|
|
1414
|
+
if campaign_file.is_file():
|
|
1415
|
+
try:
|
|
1416
|
+
from runops.core.campaign import load_campaign
|
|
1417
|
+
|
|
1418
|
+
campaign = load_campaign(project_dir)
|
|
1419
|
+
if campaign:
|
|
1420
|
+
typer.echo(f"[PASS] campaign.toml: {campaign.name}")
|
|
1421
|
+
except Exception as e:
|
|
1422
|
+
typer.echo(f"[FAIL] campaign.toml: {e}")
|
|
1423
|
+
failures.append("campaign.toml")
|
|
1424
|
+
else:
|
|
1425
|
+
typer.echo("[INFO] No campaign.toml (optional)")
|
|
1426
|
+
|
|
1427
|
+
# Final verdict
|
|
1428
|
+
if failures:
|
|
1429
|
+
typer.echo(f"\n{len(failures)} check(s) failed.")
|
|
1430
|
+
raise typer.Exit(code=1)
|
|
1431
|
+
else:
|
|
1432
|
+
typer.echo("\nAll checks passed.")
|