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/analyze.py
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""CLI commands for analysis: summarize, collect, and plot."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from runops.cli.run_lookup import resolve_run_or_cwd
|
|
11
|
+
from runops.core.actions import ActionStatus, execute_action
|
|
12
|
+
from runops.core.analysis import (
|
|
13
|
+
list_survey_plot_recipes,
|
|
14
|
+
load_survey_plot_table,
|
|
15
|
+
render_survey_plot,
|
|
16
|
+
resolve_survey_plot_recipe,
|
|
17
|
+
)
|
|
18
|
+
from runops.core.exceptions import SimctlError
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def summarize(
|
|
22
|
+
run: str = typer.Argument(None, help="Run directory or run_id (defaults to cwd)."),
|
|
23
|
+
) -> None:
|
|
24
|
+
"""Generate or update analysis/summary.json for a run."""
|
|
25
|
+
run_dir = resolve_run_or_cwd(run, search_dir=Path.cwd())
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
result = execute_action("summarize_run", run_dir=run_dir)
|
|
29
|
+
except (OSError, TypeError, json.JSONDecodeError, SimctlError) as e:
|
|
30
|
+
typer.echo(f"Error generating summary: {e}", err=True)
|
|
31
|
+
raise typer.Exit(code=1) from None
|
|
32
|
+
|
|
33
|
+
if result.status is not ActionStatus.SUCCESS:
|
|
34
|
+
typer.echo(f"Error generating summary: {result.message}", err=True)
|
|
35
|
+
raise typer.Exit(code=1)
|
|
36
|
+
|
|
37
|
+
script_path = str(result.data.get("script_path", ""))
|
|
38
|
+
if script_path:
|
|
39
|
+
typer.echo(f" Applied script: {Path(script_path).name}")
|
|
40
|
+
for warning in result.data.get("warnings", []):
|
|
41
|
+
typer.echo(f"Warning: {warning}", err=True)
|
|
42
|
+
|
|
43
|
+
summary = result.data.get("summary", {})
|
|
44
|
+
run_id = result.data.get("run_id", "???")
|
|
45
|
+
typer.echo(f"Summary written: {result.data.get('summary_path', '')}")
|
|
46
|
+
typer.echo(f" Run: {run_id}")
|
|
47
|
+
typer.echo(f" Keys: {', '.join(sorted(summary.keys()))}")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def collect(
|
|
51
|
+
survey_dir: Path = typer.Argument(None, help="Survey directory (defaults to cwd)."),
|
|
52
|
+
) -> None:
|
|
53
|
+
"""Collect summaries from all runs in a survey into aggregate artifacts."""
|
|
54
|
+
if survey_dir is None:
|
|
55
|
+
survey_dir = Path.cwd()
|
|
56
|
+
if not survey_dir.is_dir():
|
|
57
|
+
typer.echo(f"Error: directory not found: {survey_dir}", err=True)
|
|
58
|
+
raise typer.Exit(code=1)
|
|
59
|
+
|
|
60
|
+
try:
|
|
61
|
+
result = execute_action("collect_survey", survey_dir=survey_dir)
|
|
62
|
+
except (OSError, TypeError, json.JSONDecodeError, SimctlError) as e:
|
|
63
|
+
typer.echo(f"Error collecting summaries: {e}", err=True)
|
|
64
|
+
raise typer.Exit(code=1) from None
|
|
65
|
+
|
|
66
|
+
if result.status is not ActionStatus.SUCCESS:
|
|
67
|
+
typer.echo(f"Error collecting summaries: {result.message}", err=True)
|
|
68
|
+
raise typer.Exit(code=1)
|
|
69
|
+
|
|
70
|
+
typer.echo(f"Collected {result.message.removeprefix('Collected ').strip()}")
|
|
71
|
+
typer.echo(f" CSV: {result.data.get('csv_path', '')}")
|
|
72
|
+
typer.echo(f" JSON: {result.data.get('json_path', '')}")
|
|
73
|
+
typer.echo(f" Figures: {result.data.get('figures_path', '')}")
|
|
74
|
+
typer.echo(f" Report: {result.data.get('report_path', '')}")
|
|
75
|
+
generated_summaries = int(result.data.get("generated_summaries", 0))
|
|
76
|
+
if generated_summaries > 0:
|
|
77
|
+
typer.echo(
|
|
78
|
+
f" Auto-summarized: {generated_summaries} completed runs during collect"
|
|
79
|
+
)
|
|
80
|
+
missing_summaries = int(result.data.get("missing_summaries", 0))
|
|
81
|
+
if missing_summaries > 0:
|
|
82
|
+
typer.echo(f" ({missing_summaries} runs missing summary.json)")
|
|
83
|
+
for warning in result.data.get("warnings", []):
|
|
84
|
+
typer.echo(f"Warning: {warning}", err=True)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def plot(
|
|
88
|
+
survey_dir: Path = typer.Argument(None, help="Survey directory (defaults to cwd)."),
|
|
89
|
+
x: str | None = typer.Option(None, "--x", help="Column for the x-axis."),
|
|
90
|
+
y: str | None = typer.Option(None, "--y", help="Column for the y-axis."),
|
|
91
|
+
recipe: str = typer.Option(
|
|
92
|
+
"",
|
|
93
|
+
"--recipe",
|
|
94
|
+
help="Adapter-aware plot recipe name.",
|
|
95
|
+
),
|
|
96
|
+
kind: str = typer.Option(
|
|
97
|
+
"auto",
|
|
98
|
+
"--kind",
|
|
99
|
+
help="Plot kind: auto, line, scatter, or bar.",
|
|
100
|
+
),
|
|
101
|
+
group_by: str = typer.Option(
|
|
102
|
+
"",
|
|
103
|
+
"--group",
|
|
104
|
+
help="Optional column used to split data into multiple series.",
|
|
105
|
+
),
|
|
106
|
+
title: str = typer.Option("", "--title", help="Optional plot title."),
|
|
107
|
+
output: Path | None = typer.Option(
|
|
108
|
+
None,
|
|
109
|
+
"--output",
|
|
110
|
+
"-o",
|
|
111
|
+
help="Output image path (default: summary/plots/<y>_vs_<x>.png).",
|
|
112
|
+
),
|
|
113
|
+
list_columns: bool = typer.Option(
|
|
114
|
+
False,
|
|
115
|
+
"--list-columns",
|
|
116
|
+
help="Print available plot columns and exit.",
|
|
117
|
+
),
|
|
118
|
+
list_recipes: bool = typer.Option(
|
|
119
|
+
False,
|
|
120
|
+
"--list-recipes",
|
|
121
|
+
help="Print available adapter plot recipes and exit.",
|
|
122
|
+
),
|
|
123
|
+
) -> None:
|
|
124
|
+
"""Render a survey plot from explicit columns or an adapter recipe."""
|
|
125
|
+
if survey_dir is None:
|
|
126
|
+
survey_dir = Path.cwd()
|
|
127
|
+
if not survey_dir.is_dir():
|
|
128
|
+
typer.echo(f"Error: directory not found: {survey_dir}", err=True)
|
|
129
|
+
raise typer.Exit(code=1)
|
|
130
|
+
|
|
131
|
+
if list_columns:
|
|
132
|
+
try:
|
|
133
|
+
table = load_survey_plot_table(survey_dir)
|
|
134
|
+
except (OSError, TypeError, json.JSONDecodeError, SimctlError) as e:
|
|
135
|
+
typer.echo(f"Error loading plot columns: {e}", err=True)
|
|
136
|
+
raise typer.Exit(code=1) from None
|
|
137
|
+
|
|
138
|
+
typer.echo("Available columns:")
|
|
139
|
+
for column in table.columns:
|
|
140
|
+
typer.echo(f" {column}")
|
|
141
|
+
return
|
|
142
|
+
|
|
143
|
+
if list_recipes:
|
|
144
|
+
try:
|
|
145
|
+
recipes = list_survey_plot_recipes(survey_dir)
|
|
146
|
+
except (OSError, TypeError, json.JSONDecodeError, SimctlError) as e:
|
|
147
|
+
typer.echo(f"Error loading plot recipes: {e}", err=True)
|
|
148
|
+
raise typer.Exit(code=1) from None
|
|
149
|
+
|
|
150
|
+
if not recipes:
|
|
151
|
+
typer.echo("No adapter plot recipes available for this survey.")
|
|
152
|
+
return
|
|
153
|
+
|
|
154
|
+
typer.echo("Available plot recipes:")
|
|
155
|
+
for item in recipes:
|
|
156
|
+
x_candidates = ", ".join(item.x_candidates)
|
|
157
|
+
y_candidates = ", ".join(item.y_candidates)
|
|
158
|
+
group_candidates = (
|
|
159
|
+
", ".join(item.group_by_candidates)
|
|
160
|
+
if item.group_by_candidates
|
|
161
|
+
else "(none)"
|
|
162
|
+
)
|
|
163
|
+
typer.echo(f" {item.name} [{item.adapter}]")
|
|
164
|
+
if item.description:
|
|
165
|
+
typer.echo(f" {item.description}")
|
|
166
|
+
typer.echo(f" x: {x_candidates}")
|
|
167
|
+
typer.echo(f" y: {y_candidates}")
|
|
168
|
+
typer.echo(f" kind: {item.kind}")
|
|
169
|
+
typer.echo(f" group_by: {group_candidates}")
|
|
170
|
+
return
|
|
171
|
+
|
|
172
|
+
resolved_recipe = None
|
|
173
|
+
if recipe:
|
|
174
|
+
try:
|
|
175
|
+
resolved_recipe = resolve_survey_plot_recipe(survey_dir, recipe)
|
|
176
|
+
except (OSError, TypeError, json.JSONDecodeError, SimctlError) as e:
|
|
177
|
+
typer.echo(f"Error resolving plot recipe: {e}", err=True)
|
|
178
|
+
raise typer.Exit(code=1) from None
|
|
179
|
+
|
|
180
|
+
if not x and resolved_recipe is not None:
|
|
181
|
+
x = resolved_recipe.x
|
|
182
|
+
if not y and resolved_recipe is not None:
|
|
183
|
+
y = resolved_recipe.y
|
|
184
|
+
if not group_by and resolved_recipe is not None:
|
|
185
|
+
group_by = resolved_recipe.group_by
|
|
186
|
+
if kind == "auto" and resolved_recipe is not None:
|
|
187
|
+
kind = resolved_recipe.recipe.kind
|
|
188
|
+
if not title and resolved_recipe is not None:
|
|
189
|
+
title = resolved_recipe.recipe.title
|
|
190
|
+
|
|
191
|
+
if not x or not y:
|
|
192
|
+
typer.echo(
|
|
193
|
+
"Error: --x and --y are required unless --recipe, --list-columns, "
|
|
194
|
+
"or --list-recipes is used.",
|
|
195
|
+
err=True,
|
|
196
|
+
)
|
|
197
|
+
raise typer.Exit(code=1)
|
|
198
|
+
|
|
199
|
+
try:
|
|
200
|
+
result = render_survey_plot(
|
|
201
|
+
survey_dir,
|
|
202
|
+
x=x,
|
|
203
|
+
y=y,
|
|
204
|
+
kind=kind,
|
|
205
|
+
group_by=group_by,
|
|
206
|
+
title=title,
|
|
207
|
+
output_path=output,
|
|
208
|
+
)
|
|
209
|
+
except (OSError, TypeError, json.JSONDecodeError, SimctlError) as e:
|
|
210
|
+
typer.echo(f"Error rendering plot: {e}", err=True)
|
|
211
|
+
raise typer.Exit(code=1) from None
|
|
212
|
+
|
|
213
|
+
typer.echo(f"Plot written: {result.output_path}")
|
|
214
|
+
if resolved_recipe is not None:
|
|
215
|
+
typer.echo(f" Recipe: {resolved_recipe.recipe.name}")
|
|
216
|
+
typer.echo(f" Kind: {result.kind}")
|
|
217
|
+
typer.echo(f" Points: {result.points_plotted}")
|
|
218
|
+
if result.generated_summaries > 0:
|
|
219
|
+
typer.echo(
|
|
220
|
+
" Auto-summarized:"
|
|
221
|
+
f" {result.generated_summaries} completed runs during plot"
|
|
222
|
+
)
|
runops/cli/clone.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""CLI command for cloning and deriving runs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import shutil
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
|
|
11
|
+
from runops.cli.run_lookup import resolve_run_or_cwd
|
|
12
|
+
from runops.core.discovery import collect_existing_run_ids
|
|
13
|
+
from runops.core.exceptions import SimctlError
|
|
14
|
+
from runops.core.manifest import ManifestData, read_manifest, write_manifest
|
|
15
|
+
from runops.core.run import next_run_id
|
|
16
|
+
from runops.core.state import RunState
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def clone(
|
|
20
|
+
run: str = typer.Argument(None, help="Run directory or run_id (defaults to cwd)."),
|
|
21
|
+
dest: Optional[Path] = typer.Option(
|
|
22
|
+
None, "--dest", "-d", help="Destination directory (defaults to cwd)."
|
|
23
|
+
),
|
|
24
|
+
set_params: Optional[list[str]] = typer.Option(
|
|
25
|
+
None, "--set", help="Override parameters as key=value."
|
|
26
|
+
),
|
|
27
|
+
) -> None:
|
|
28
|
+
"""Clone a run, optionally modifying parameters."""
|
|
29
|
+
search_dir = Path.cwd()
|
|
30
|
+
source_dir = resolve_run_or_cwd(run, search_dir=search_dir)
|
|
31
|
+
|
|
32
|
+
try:
|
|
33
|
+
source_manifest = read_manifest(source_dir)
|
|
34
|
+
except SimctlError as e:
|
|
35
|
+
typer.echo(f"Error reading manifest: {e}", err=True)
|
|
36
|
+
raise typer.Exit(code=1) from None
|
|
37
|
+
|
|
38
|
+
# Determine destination parent directory
|
|
39
|
+
dest_parent = dest or source_dir.parent
|
|
40
|
+
|
|
41
|
+
# Generate new run_id, searching both cwd and dest for existing IDs
|
|
42
|
+
try:
|
|
43
|
+
existing_ids = collect_existing_run_ids(search_dir)
|
|
44
|
+
if dest_parent != search_dir:
|
|
45
|
+
existing_ids |= collect_existing_run_ids(dest_parent)
|
|
46
|
+
new_run_id = next_run_id(existing_ids)
|
|
47
|
+
except SimctlError as e:
|
|
48
|
+
typer.echo(f"Error generating run_id: {e}", err=True)
|
|
49
|
+
raise typer.Exit(code=1) from None
|
|
50
|
+
|
|
51
|
+
new_run_dir = dest_parent / new_run_id
|
|
52
|
+
|
|
53
|
+
try:
|
|
54
|
+
# Create run directory structure
|
|
55
|
+
new_run_dir.mkdir(parents=True)
|
|
56
|
+
for subdir in ("input", "submit", "work", "analysis", "status"):
|
|
57
|
+
(new_run_dir / subdir).mkdir(exist_ok=True)
|
|
58
|
+
|
|
59
|
+
# Copy input files from source
|
|
60
|
+
source_input = source_dir / "input"
|
|
61
|
+
if source_input.is_dir():
|
|
62
|
+
dest_input = new_run_dir / "input"
|
|
63
|
+
# Remove the empty dir we just created, then copy tree
|
|
64
|
+
dest_input.rmdir()
|
|
65
|
+
shutil.copytree(source_input, dest_input)
|
|
66
|
+
|
|
67
|
+
# Copy submit/ directory (including job.sh) from source
|
|
68
|
+
source_submit = source_dir / "submit"
|
|
69
|
+
if source_submit.is_dir():
|
|
70
|
+
dest_submit = new_run_dir / "submit"
|
|
71
|
+
dest_submit.rmdir()
|
|
72
|
+
shutil.copytree(source_submit, dest_submit)
|
|
73
|
+
|
|
74
|
+
# Build new manifest
|
|
75
|
+
source_run_id = source_manifest.run.get("id", "")
|
|
76
|
+
new_manifest = ManifestData.from_dict(source_manifest.to_dict())
|
|
77
|
+
new_manifest.run["id"] = new_run_id
|
|
78
|
+
new_manifest.run["status"] = RunState.CREATED.value
|
|
79
|
+
new_manifest.run["display_name"] = f"clone of {source_run_id}"
|
|
80
|
+
new_manifest.origin["parent_run"] = source_run_id
|
|
81
|
+
|
|
82
|
+
# Apply parameter overrides
|
|
83
|
+
if set_params:
|
|
84
|
+
for param in set_params:
|
|
85
|
+
if "=" not in param:
|
|
86
|
+
typer.echo(
|
|
87
|
+
f"Error: invalid --set format {param!r}, expected key=value",
|
|
88
|
+
err=True,
|
|
89
|
+
)
|
|
90
|
+
raise typer.Exit(code=1)
|
|
91
|
+
key, value = param.split("=", 1)
|
|
92
|
+
new_manifest.params_snapshot[key.strip()] = value.strip()
|
|
93
|
+
|
|
94
|
+
write_manifest(new_run_dir, new_manifest)
|
|
95
|
+
|
|
96
|
+
except SimctlError as e:
|
|
97
|
+
typer.echo(f"Error creating clone: {e}", err=True)
|
|
98
|
+
raise typer.Exit(code=1) from None
|
|
99
|
+
except OSError as e:
|
|
100
|
+
typer.echo(f"Error creating clone directory: {e}", err=True)
|
|
101
|
+
raise typer.Exit(code=1) from None
|
|
102
|
+
|
|
103
|
+
typer.echo(f"Cloned {source_run_id} -> {new_run_id}")
|
|
104
|
+
typer.echo(f" Path: {new_run_dir}")
|
runops/cli/config.py
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""CLI commands for project configuration management."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import io
|
|
6
|
+
import logging
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Annotated, Any, Optional
|
|
10
|
+
|
|
11
|
+
import typer
|
|
12
|
+
|
|
13
|
+
if sys.version_info >= (3, 11):
|
|
14
|
+
import tomllib
|
|
15
|
+
else:
|
|
16
|
+
import tomli as tomllib
|
|
17
|
+
|
|
18
|
+
try:
|
|
19
|
+
import tomli_w
|
|
20
|
+
except ImportError:
|
|
21
|
+
tomli_w = None # type: ignore[assignment]
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
config_app = typer.Typer(
|
|
26
|
+
name="config",
|
|
27
|
+
help="View and modify project configuration.",
|
|
28
|
+
no_args_is_help=True,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _find_project_dir(path: Path | None) -> Path:
|
|
33
|
+
"""Resolve and validate the project directory."""
|
|
34
|
+
project_dir = (path or Path.cwd()).resolve()
|
|
35
|
+
if not (project_dir / "runops.toml").exists():
|
|
36
|
+
typer.echo(f"Error: runops.toml not found in {project_dir}")
|
|
37
|
+
raise typer.Exit(code=1)
|
|
38
|
+
return project_dir
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _load_toml(path: Path) -> dict[str, Any]:
|
|
42
|
+
"""Load a TOML file."""
|
|
43
|
+
with open(path, "rb") as f:
|
|
44
|
+
return tomllib.load(f)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _write_toml(path: Path, data: dict[str, Any]) -> None:
|
|
48
|
+
"""Write a TOML file."""
|
|
49
|
+
if tomli_w is None:
|
|
50
|
+
msg = "tomli_w is required to write TOML files"
|
|
51
|
+
raise RuntimeError(msg)
|
|
52
|
+
with open(path, "wb") as f:
|
|
53
|
+
tomli_w.dump(data, f)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _toml_to_str(data: dict[str, Any]) -> str:
|
|
57
|
+
"""Serialize dict to TOML string."""
|
|
58
|
+
if tomli_w is None:
|
|
59
|
+
msg = "tomli_w is required to serialize TOML"
|
|
60
|
+
raise RuntimeError(msg)
|
|
61
|
+
buf = io.BytesIO()
|
|
62
|
+
tomli_w.dump(data, buf)
|
|
63
|
+
return buf.getvalue().decode("utf-8")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@config_app.command("show")
|
|
67
|
+
def show(
|
|
68
|
+
path: Annotated[
|
|
69
|
+
Optional[Path],
|
|
70
|
+
typer.Option("--path", "-p", help="Project directory."),
|
|
71
|
+
] = None,
|
|
72
|
+
) -> None:
|
|
73
|
+
"""Display current project configuration."""
|
|
74
|
+
project_dir = _find_project_dir(path)
|
|
75
|
+
|
|
76
|
+
# runops.toml
|
|
77
|
+
typer.echo("=== runops.toml ===")
|
|
78
|
+
simproject = project_dir / "runops.toml"
|
|
79
|
+
typer.echo(simproject.read_text(encoding="utf-8"))
|
|
80
|
+
|
|
81
|
+
# simulators.toml
|
|
82
|
+
typer.echo("=== simulators.toml ===")
|
|
83
|
+
simulators = project_dir / "simulators.toml"
|
|
84
|
+
if simulators.exists():
|
|
85
|
+
typer.echo(simulators.read_text(encoding="utf-8"))
|
|
86
|
+
else:
|
|
87
|
+
typer.echo("(not found)\n")
|
|
88
|
+
|
|
89
|
+
# launchers.toml
|
|
90
|
+
typer.echo("=== launchers.toml ===")
|
|
91
|
+
launchers = project_dir / "launchers.toml"
|
|
92
|
+
if launchers.exists():
|
|
93
|
+
typer.echo(launchers.read_text(encoding="utf-8"))
|
|
94
|
+
else:
|
|
95
|
+
typer.echo("(not found)\n")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@config_app.command("add-simulator")
|
|
99
|
+
def add_simulator(
|
|
100
|
+
simulator: Annotated[
|
|
101
|
+
Optional[str],
|
|
102
|
+
typer.Argument(help="Simulator name (e.g. emses, beach)."),
|
|
103
|
+
] = None,
|
|
104
|
+
path: Annotated[
|
|
105
|
+
Optional[Path],
|
|
106
|
+
typer.Option("--path", "-p", help="Project directory."),
|
|
107
|
+
] = None,
|
|
108
|
+
) -> None:
|
|
109
|
+
"""Add a simulator to simulators.toml (interactive)."""
|
|
110
|
+
import runops.adapters # noqa: F401
|
|
111
|
+
from runops.adapters.registry import get_global_registry
|
|
112
|
+
|
|
113
|
+
project_dir = _find_project_dir(path)
|
|
114
|
+
registry = get_global_registry()
|
|
115
|
+
available = registry.list_adapters()
|
|
116
|
+
|
|
117
|
+
# Select simulator
|
|
118
|
+
if not simulator:
|
|
119
|
+
typer.echo("\nAvailable simulators:")
|
|
120
|
+
for i, name in enumerate(available, 1):
|
|
121
|
+
typer.echo(f" {i}. {name}")
|
|
122
|
+
|
|
123
|
+
selection = typer.prompt("\nSelect simulator (number or name)")
|
|
124
|
+
if selection.isdigit():
|
|
125
|
+
idx = int(selection) - 1
|
|
126
|
+
if 0 <= idx < len(available):
|
|
127
|
+
simulator = available[idx]
|
|
128
|
+
else:
|
|
129
|
+
typer.echo("Invalid selection.")
|
|
130
|
+
raise typer.Exit(code=1)
|
|
131
|
+
else:
|
|
132
|
+
simulator = selection
|
|
133
|
+
|
|
134
|
+
if simulator not in available:
|
|
135
|
+
typer.echo(
|
|
136
|
+
f"Unknown simulator: '{simulator}'. Available: {', '.join(available)}"
|
|
137
|
+
)
|
|
138
|
+
raise typer.Exit(code=1)
|
|
139
|
+
|
|
140
|
+
# Load existing config
|
|
141
|
+
sim_path = project_dir / "simulators.toml"
|
|
142
|
+
existing = _load_toml(sim_path) if sim_path.exists() else {"simulators": {}}
|
|
143
|
+
|
|
144
|
+
if "simulators" not in existing:
|
|
145
|
+
existing["simulators"] = {}
|
|
146
|
+
|
|
147
|
+
if simulator in existing["simulators"] and not typer.confirm(
|
|
148
|
+
f"'{simulator}' already exists. Overwrite?", default=False
|
|
149
|
+
):
|
|
150
|
+
typer.echo("Cancelled.")
|
|
151
|
+
raise typer.Exit()
|
|
152
|
+
|
|
153
|
+
# Interactive config
|
|
154
|
+
adapter_cls = registry.get(simulator)
|
|
155
|
+
config = adapter_cls.interactive_config()
|
|
156
|
+
|
|
157
|
+
existing["simulators"][simulator] = config
|
|
158
|
+
_write_toml(sim_path, existing)
|
|
159
|
+
|
|
160
|
+
typer.echo(f"\nAdded simulator '{simulator}' to simulators.toml:")
|
|
161
|
+
typer.echo(_toml_to_str({"simulators": {simulator: config}}))
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@config_app.command("add-launcher")
|
|
165
|
+
def add_launcher(
|
|
166
|
+
path: Annotated[
|
|
167
|
+
Optional[Path],
|
|
168
|
+
typer.Option("--path", "-p", help="Project directory."),
|
|
169
|
+
] = None,
|
|
170
|
+
) -> None:
|
|
171
|
+
"""Add a launcher profile to launchers.toml (interactive)."""
|
|
172
|
+
project_dir = _find_project_dir(path)
|
|
173
|
+
|
|
174
|
+
typer.echo("\nAvailable launcher types:")
|
|
175
|
+
typer.echo(" 1. srun (Slurm)")
|
|
176
|
+
typer.echo(" 2. mpirun (OpenMPI)")
|
|
177
|
+
typer.echo(" 3. mpiexec (MPICH)")
|
|
178
|
+
|
|
179
|
+
selection = typer.prompt("\nSelect launcher type (number or name)")
|
|
180
|
+
launcher_map = {"1": "srun", "2": "mpirun", "3": "mpiexec"}
|
|
181
|
+
launcher_type = launcher_map.get(selection.strip(), selection.strip())
|
|
182
|
+
|
|
183
|
+
if launcher_type not in ("srun", "mpirun", "mpiexec"):
|
|
184
|
+
typer.echo(f"Unknown launcher type: '{launcher_type}'")
|
|
185
|
+
raise typer.Exit(code=1)
|
|
186
|
+
|
|
187
|
+
launcher_name = typer.prompt("Launcher profile name", default="default")
|
|
188
|
+
|
|
189
|
+
config: dict[str, Any] = {"type": launcher_type}
|
|
190
|
+
|
|
191
|
+
extra_args = typer.prompt(
|
|
192
|
+
f"Extra {launcher_type} arguments (Enter to skip)", default=""
|
|
193
|
+
)
|
|
194
|
+
if extra_args:
|
|
195
|
+
config["args"] = extra_args
|
|
196
|
+
|
|
197
|
+
# Load existing
|
|
198
|
+
launcher_path = project_dir / "launchers.toml"
|
|
199
|
+
if launcher_path.exists():
|
|
200
|
+
existing = _load_toml(launcher_path)
|
|
201
|
+
else:
|
|
202
|
+
existing = {"launchers": {}}
|
|
203
|
+
|
|
204
|
+
if "launchers" not in existing:
|
|
205
|
+
existing["launchers"] = {}
|
|
206
|
+
|
|
207
|
+
if launcher_name in existing["launchers"] and not typer.confirm(
|
|
208
|
+
f"'{launcher_name}' already exists. Overwrite?", default=False
|
|
209
|
+
):
|
|
210
|
+
typer.echo("Cancelled.")
|
|
211
|
+
raise typer.Exit()
|
|
212
|
+
|
|
213
|
+
existing["launchers"][launcher_name] = config
|
|
214
|
+
_write_toml(launcher_path, existing)
|
|
215
|
+
|
|
216
|
+
typer.echo(f"\nAdded launcher '{launcher_name}' to launchers.toml:")
|
|
217
|
+
typer.echo(_toml_to_str({"launchers": {launcher_name: config}}))
|
runops/cli/context.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""CLI command: runops context — project context bundle for agents."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from runops.core.project import find_project_root
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def context(
|
|
14
|
+
directory: Path = typer.Argument(
|
|
15
|
+
Path("."),
|
|
16
|
+
help="Project directory (default: cwd).",
|
|
17
|
+
exists=True,
|
|
18
|
+
),
|
|
19
|
+
output_json: bool = typer.Option(
|
|
20
|
+
True,
|
|
21
|
+
"--json/--no-json",
|
|
22
|
+
help="Output as JSON (default: true).",
|
|
23
|
+
),
|
|
24
|
+
) -> None:
|
|
25
|
+
"""Show project context bundle (designed for AI agents)."""
|
|
26
|
+
from runops.core.context import build_project_context
|
|
27
|
+
|
|
28
|
+
root = find_project_root(directory)
|
|
29
|
+
ctx = build_project_context(root)
|
|
30
|
+
|
|
31
|
+
if output_json:
|
|
32
|
+
typer.echo(json.dumps(ctx, indent=2, ensure_ascii=False))
|
|
33
|
+
else:
|
|
34
|
+
# Simple text summary
|
|
35
|
+
proj = ctx.get("project", {})
|
|
36
|
+
typer.echo(f"Project: {proj.get('name', '?')}")
|
|
37
|
+
typer.echo(f"Root: {proj.get('root', '?')}")
|
|
38
|
+
|
|
39
|
+
camp = ctx.get("campaign", {})
|
|
40
|
+
if camp.get("hypothesis"):
|
|
41
|
+
typer.echo(f"Hypothesis: {camp['hypothesis']}")
|
|
42
|
+
|
|
43
|
+
sims = ctx.get("simulators", [])
|
|
44
|
+
if sims:
|
|
45
|
+
typer.echo(f"Simulators: {', '.join(sims)}")
|
|
46
|
+
|
|
47
|
+
runs = ctx.get("runs", {})
|
|
48
|
+
if runs.get("total", 0) > 0:
|
|
49
|
+
parts = [f"{k}={v}" for k, v in runs.items()]
|
|
50
|
+
typer.echo(f"Runs: {', '.join(parts)}")
|
|
51
|
+
|
|
52
|
+
failures = ctx.get("recent_failures", [])
|
|
53
|
+
if failures:
|
|
54
|
+
typer.echo(f"Recent failures ({len(failures)}):")
|
|
55
|
+
for f in failures:
|
|
56
|
+
typer.echo(f" {f['run_id']}: {f['reason']}")
|