researchloop 0.1.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.
- researchloop/__init__.py +1 -0
- researchloop/__main__.py +3 -0
- researchloop/cli.py +1138 -0
- researchloop/clusters/__init__.py +4 -0
- researchloop/clusters/monitor.py +199 -0
- researchloop/clusters/ssh.py +183 -0
- researchloop/comms/__init__.py +0 -0
- researchloop/comms/base.py +34 -0
- researchloop/comms/conversation.py +465 -0
- researchloop/comms/ntfy.py +95 -0
- researchloop/comms/router.py +71 -0
- researchloop/comms/slack.py +188 -0
- researchloop/core/__init__.py +0 -0
- researchloop/core/auth.py +78 -0
- researchloop/core/config.py +328 -0
- researchloop/core/credentials.py +38 -0
- researchloop/core/models.py +119 -0
- researchloop/core/orchestrator.py +910 -0
- researchloop/dashboard/__init__.py +0 -0
- researchloop/dashboard/app.py +15 -0
- researchloop/dashboard/auth.py +60 -0
- researchloop/dashboard/routes.py +912 -0
- researchloop/dashboard/templates/base.html +84 -0
- researchloop/dashboard/templates/login.html +12 -0
- researchloop/dashboard/templates/loop_detail.html +58 -0
- researchloop/dashboard/templates/loops.html +61 -0
- researchloop/dashboard/templates/setup.html +14 -0
- researchloop/dashboard/templates/sprint_detail.html +109 -0
- researchloop/dashboard/templates/sprints.html +48 -0
- researchloop/dashboard/templates/studies.html +18 -0
- researchloop/dashboard/templates/study_detail.html +64 -0
- researchloop/db/__init__.py +5 -0
- researchloop/db/database.py +86 -0
- researchloop/db/migrations.py +172 -0
- researchloop/db/queries.py +351 -0
- researchloop/runner/__init__.py +1 -0
- researchloop/runner/claude.py +169 -0
- researchloop/runner/job_templates/sge.sh.j2 +319 -0
- researchloop/runner/job_templates/slurm.sh.j2 +336 -0
- researchloop/runner/main.py +156 -0
- researchloop/runner/pipeline.py +272 -0
- researchloop/runner/templates/fix_issues.md.j2 +11 -0
- researchloop/runner/templates/idea_generator.md.j2 +16 -0
- researchloop/runner/templates/red_team.md.j2 +15 -0
- researchloop/runner/templates/report.md.j2 +31 -0
- researchloop/runner/templates/research_sprint.md.j2 +51 -0
- researchloop/runner/templates/summarizer.md.j2 +7 -0
- researchloop/runner/upload.py +153 -0
- researchloop/schedulers/__init__.py +11 -0
- researchloop/schedulers/base.py +43 -0
- researchloop/schedulers/local.py +188 -0
- researchloop/schedulers/sge.py +163 -0
- researchloop/schedulers/slurm.py +179 -0
- researchloop/sprints/__init__.py +0 -0
- researchloop/sprints/auto_loop.py +458 -0
- researchloop/sprints/manager.py +750 -0
- researchloop/studies/__init__.py +0 -0
- researchloop/studies/manager.py +102 -0
- researchloop-0.1.0.dist-info/METADATA +596 -0
- researchloop-0.1.0.dist-info/RECORD +63 -0
- researchloop-0.1.0.dist-info/WHEEL +4 -0
- researchloop-0.1.0.dist-info/entry_points.txt +3 -0
- researchloop-0.1.0.dist-info/licenses/LICENSE +21 -0
researchloop/cli.py
ADDED
|
@@ -0,0 +1,1138 @@
|
|
|
1
|
+
"""CLI entry point for researchloop."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import shutil
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import click
|
|
12
|
+
import httpx
|
|
13
|
+
|
|
14
|
+
from researchloop import __version__
|
|
15
|
+
|
|
16
|
+
# ---------------------------------------------------------------------------
|
|
17
|
+
# Async helper
|
|
18
|
+
# ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def run_async(coro: Any) -> Any:
|
|
22
|
+
"""Run an async coroutine from synchronous click code."""
|
|
23
|
+
try:
|
|
24
|
+
loop = asyncio.get_running_loop()
|
|
25
|
+
except RuntimeError:
|
|
26
|
+
loop = None
|
|
27
|
+
|
|
28
|
+
if loop and loop.is_running():
|
|
29
|
+
# Already inside an event loop (e.g. Jupyter) -- create a new one.
|
|
30
|
+
import concurrent.futures
|
|
31
|
+
|
|
32
|
+
with concurrent.futures.ThreadPoolExecutor() as pool:
|
|
33
|
+
return pool.submit(asyncio.run, coro).result()
|
|
34
|
+
return asyncio.run(coro)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# ---------------------------------------------------------------------------
|
|
38
|
+
# Formatting helpers
|
|
39
|
+
# ---------------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
STATUS_COLORS: dict[str, str] = {
|
|
42
|
+
"pending": "yellow",
|
|
43
|
+
"submitted": "yellow",
|
|
44
|
+
"running": "blue",
|
|
45
|
+
"research": "blue",
|
|
46
|
+
"red_team": "magenta",
|
|
47
|
+
"fixing": "cyan",
|
|
48
|
+
"validating": "cyan",
|
|
49
|
+
"reporting": "cyan",
|
|
50
|
+
"summarizing": "cyan",
|
|
51
|
+
"uploading": "cyan",
|
|
52
|
+
"completed": "green",
|
|
53
|
+
"failed": "red",
|
|
54
|
+
"cancelled": "red",
|
|
55
|
+
"stopped": "red",
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def styled_status(status: str) -> str:
|
|
60
|
+
"""Return a click-styled status string."""
|
|
61
|
+
color = STATUS_COLORS.get(status, "white")
|
|
62
|
+
return click.style(status, fg=color, bold=True)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def print_table(headers: list[str], rows: list[list[str]]) -> None:
|
|
66
|
+
"""Print a simple aligned table."""
|
|
67
|
+
if not rows:
|
|
68
|
+
click.echo(click.style(" (none)", dim=True))
|
|
69
|
+
return
|
|
70
|
+
|
|
71
|
+
col_widths = [len(h) for h in headers]
|
|
72
|
+
for row in rows:
|
|
73
|
+
for i, cell in enumerate(row):
|
|
74
|
+
col_widths[i] = max(col_widths[i], len(click.unstyle(cell)))
|
|
75
|
+
|
|
76
|
+
header_line = " ".join(h.ljust(col_widths[i]) for i, h in enumerate(headers))
|
|
77
|
+
click.echo(click.style(header_line, bold=True))
|
|
78
|
+
click.echo(" ".join("-" * w for w in col_widths))
|
|
79
|
+
for row in rows:
|
|
80
|
+
line = " ".join(
|
|
81
|
+
cell + " " * (col_widths[i] - len(click.unstyle(cell)))
|
|
82
|
+
for i, cell in enumerate(row)
|
|
83
|
+
)
|
|
84
|
+
click.echo(line)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def truncate(text: str | None, length: int = 50) -> str:
|
|
88
|
+
"""Truncate a string and append an ellipsis if needed."""
|
|
89
|
+
if not text:
|
|
90
|
+
return ""
|
|
91
|
+
text = text.replace("\n", " ").strip()
|
|
92
|
+
if len(text) > length:
|
|
93
|
+
return text[: length - 1] + "\u2026"
|
|
94
|
+
return text
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# ---------------------------------------------------------------------------
|
|
98
|
+
# Remote API helper
|
|
99
|
+
# ---------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _resolve_connection(
|
|
103
|
+
config: Any | None = None,
|
|
104
|
+
) -> tuple[str, dict[str, str]]:
|
|
105
|
+
"""Resolve orchestrator URL and auth headers.
|
|
106
|
+
|
|
107
|
+
Checks (in order): config object, saved credentials.
|
|
108
|
+
Returns ``(base_url, headers)``.
|
|
109
|
+
"""
|
|
110
|
+
from researchloop.core.credentials import load_credentials
|
|
111
|
+
|
|
112
|
+
url: str | None = None
|
|
113
|
+
headers: dict[str, str] = {}
|
|
114
|
+
|
|
115
|
+
# 1. Config / env vars (shared_secret for server-side usage).
|
|
116
|
+
if config is not None:
|
|
117
|
+
url = config.orchestrator_url
|
|
118
|
+
if config.shared_secret:
|
|
119
|
+
headers["X-Shared-Secret"] = config.shared_secret
|
|
120
|
+
|
|
121
|
+
# 2. Saved credentials (from `researchloop connect`).
|
|
122
|
+
if not url:
|
|
123
|
+
creds = load_credentials()
|
|
124
|
+
if creds:
|
|
125
|
+
url = creds["url"]
|
|
126
|
+
headers["Authorization"] = f"Bearer {creds['token']}"
|
|
127
|
+
|
|
128
|
+
if not url:
|
|
129
|
+
raise click.ClickException(
|
|
130
|
+
"Not connected to an orchestrator. Run:\n researchloop connect <url>"
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
return url.rstrip("/"), headers
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _reauth(url: str) -> dict[str, str]:
|
|
137
|
+
"""Prompt for password, get a new token, save it, return headers."""
|
|
138
|
+
from researchloop.core.credentials import save_credentials
|
|
139
|
+
|
|
140
|
+
click.echo("Session expired. Please re-authenticate.")
|
|
141
|
+
password = click.prompt("Password", type=str, hide_input=True)
|
|
142
|
+
|
|
143
|
+
resp = httpx.post(
|
|
144
|
+
f"{url}/api/auth",
|
|
145
|
+
json={"password": password},
|
|
146
|
+
timeout=10,
|
|
147
|
+
)
|
|
148
|
+
if resp.status_code == 401:
|
|
149
|
+
raise click.ClickException("Invalid password.")
|
|
150
|
+
if resp.status_code >= 400:
|
|
151
|
+
raise click.ClickException(f"Auth error {resp.status_code}: {resp.text[:200]}")
|
|
152
|
+
|
|
153
|
+
token = resp.json()["token"]
|
|
154
|
+
save_credentials(url, token)
|
|
155
|
+
click.echo(click.style("Re-authenticated!", fg="green"))
|
|
156
|
+
return {"Authorization": f"Bearer {token}"}
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _api_post(
|
|
160
|
+
config: Any | None,
|
|
161
|
+
path: str,
|
|
162
|
+
body: dict | None = None,
|
|
163
|
+
) -> dict:
|
|
164
|
+
"""POST to the orchestrator API."""
|
|
165
|
+
url, headers = _resolve_connection(config)
|
|
166
|
+
full_url = f"{url}{path}"
|
|
167
|
+
try:
|
|
168
|
+
resp = httpx.post(
|
|
169
|
+
full_url,
|
|
170
|
+
json=body or {},
|
|
171
|
+
headers=headers,
|
|
172
|
+
timeout=30,
|
|
173
|
+
)
|
|
174
|
+
if resp.status_code == 401 and "Authorization" in headers:
|
|
175
|
+
headers = _reauth(url)
|
|
176
|
+
resp = httpx.post(
|
|
177
|
+
full_url,
|
|
178
|
+
json=body or {},
|
|
179
|
+
headers=headers,
|
|
180
|
+
timeout=30,
|
|
181
|
+
)
|
|
182
|
+
if resp.status_code >= 400:
|
|
183
|
+
detail = resp.text[:200]
|
|
184
|
+
raise click.ClickException(f"API error {resp.status_code}: {detail}")
|
|
185
|
+
return resp.json()
|
|
186
|
+
except httpx.ConnectError:
|
|
187
|
+
raise click.ClickException(f"Cannot connect to orchestrator at {url}")
|
|
188
|
+
except httpx.TimeoutException:
|
|
189
|
+
raise click.ClickException(f"Request timed out: {full_url}")
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _api_get(config: Any | None, path: str) -> dict:
|
|
193
|
+
"""GET from the orchestrator API."""
|
|
194
|
+
url, headers = _resolve_connection(config)
|
|
195
|
+
full_url = f"{url}{path}"
|
|
196
|
+
try:
|
|
197
|
+
resp = httpx.get(full_url, headers=headers, timeout=30)
|
|
198
|
+
if resp.status_code == 401 and "Authorization" in headers:
|
|
199
|
+
headers = _reauth(url)
|
|
200
|
+
resp = httpx.get(full_url, headers=headers, timeout=30)
|
|
201
|
+
if resp.status_code >= 400:
|
|
202
|
+
detail = resp.text[:200]
|
|
203
|
+
raise click.ClickException(f"API error {resp.status_code}: {detail}")
|
|
204
|
+
return resp.json()
|
|
205
|
+
except httpx.ConnectError:
|
|
206
|
+
raise click.ClickException(f"Cannot connect to orchestrator at {url}")
|
|
207
|
+
except httpx.TimeoutException:
|
|
208
|
+
raise click.ClickException(f"Request timed out: {full_url}")
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
# ---------------------------------------------------------------------------
|
|
212
|
+
# Config + DB helpers
|
|
213
|
+
# ---------------------------------------------------------------------------
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _load_config(config_path: str | None) -> Any:
|
|
217
|
+
"""Load config, raising a ClickException on failure."""
|
|
218
|
+
from researchloop.core.config import load_config
|
|
219
|
+
|
|
220
|
+
try:
|
|
221
|
+
return load_config(config_path)
|
|
222
|
+
except FileNotFoundError as exc:
|
|
223
|
+
raise click.ClickException(str(exc))
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _try_load_config(config_path: str | None) -> Any | None:
|
|
227
|
+
"""Try to load config; return None if not found.
|
|
228
|
+
|
|
229
|
+
Used by commands that can work with just saved credentials.
|
|
230
|
+
"""
|
|
231
|
+
from researchloop.core.config import load_config
|
|
232
|
+
|
|
233
|
+
try:
|
|
234
|
+
return load_config(config_path)
|
|
235
|
+
except FileNotFoundError:
|
|
236
|
+
return None
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
async def _open_db(config: Any) -> Any:
|
|
240
|
+
"""Open and return a connected Database."""
|
|
241
|
+
from researchloop.db.database import Database
|
|
242
|
+
|
|
243
|
+
db = Database(config.db_path)
|
|
244
|
+
await db.connect()
|
|
245
|
+
return db
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
async def _ensure_studies_synced(config: Any, db: Any) -> None:
|
|
249
|
+
"""Make sure every study from the config file exists in the database."""
|
|
250
|
+
from researchloop.db import queries
|
|
251
|
+
|
|
252
|
+
for study_cfg in config.studies:
|
|
253
|
+
existing = await queries.get_study(db, study_cfg.name)
|
|
254
|
+
if existing is None:
|
|
255
|
+
await queries.create_study(
|
|
256
|
+
db,
|
|
257
|
+
name=study_cfg.name,
|
|
258
|
+
cluster=study_cfg.cluster,
|
|
259
|
+
description=study_cfg.description or None,
|
|
260
|
+
claude_md_path=study_cfg.claude_md_path or None,
|
|
261
|
+
sprints_dir=study_cfg.sprints_dir or study_cfg.name,
|
|
262
|
+
config_json=json.dumps(
|
|
263
|
+
{
|
|
264
|
+
"max_sprint_duration_hours": (
|
|
265
|
+
study_cfg.max_sprint_duration_hours
|
|
266
|
+
),
|
|
267
|
+
"red_team_max_rounds": study_cfg.red_team_max_rounds,
|
|
268
|
+
}
|
|
269
|
+
),
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
# ---------------------------------------------------------------------------
|
|
274
|
+
# Top-level group
|
|
275
|
+
# ---------------------------------------------------------------------------
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
@click.group()
|
|
279
|
+
@click.version_option(version=__version__, prog_name="researchloop")
|
|
280
|
+
@click.option(
|
|
281
|
+
"--config",
|
|
282
|
+
"-c",
|
|
283
|
+
"config_path",
|
|
284
|
+
type=click.Path(),
|
|
285
|
+
default=None,
|
|
286
|
+
help="Path to researchloop.toml",
|
|
287
|
+
)
|
|
288
|
+
@click.pass_context
|
|
289
|
+
def cli(ctx: click.Context, config_path: str | None) -> None:
|
|
290
|
+
"""ResearchLoop: Auto-Research Sprint Platform"""
|
|
291
|
+
ctx.ensure_object(dict)
|
|
292
|
+
ctx.obj["config_path"] = config_path
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
# ---------------------------------------------------------------------------
|
|
296
|
+
# init
|
|
297
|
+
# ---------------------------------------------------------------------------
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
@cli.command()
|
|
301
|
+
@click.option(
|
|
302
|
+
"--path",
|
|
303
|
+
"-p",
|
|
304
|
+
type=click.Path(),
|
|
305
|
+
default=".",
|
|
306
|
+
help="Directory to initialize",
|
|
307
|
+
)
|
|
308
|
+
def init(path: str) -> None:
|
|
309
|
+
"""Initialize a new ResearchLoop project with example config."""
|
|
310
|
+
target = Path(path).resolve()
|
|
311
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
312
|
+
|
|
313
|
+
config_dest = target / "researchloop.toml"
|
|
314
|
+
example_src = Path(__file__).resolve().parent.parent / "researchloop.toml.example"
|
|
315
|
+
|
|
316
|
+
if config_dest.exists():
|
|
317
|
+
raise click.ClickException(f"Config file already exists: {config_dest}")
|
|
318
|
+
|
|
319
|
+
if example_src.exists():
|
|
320
|
+
shutil.copy2(example_src, config_dest)
|
|
321
|
+
else:
|
|
322
|
+
# Fall back to a minimal config if the example is not found.
|
|
323
|
+
config_dest.write_text(
|
|
324
|
+
"# researchloop configuration\n"
|
|
325
|
+
'db_path = "researchloop.db"\n'
|
|
326
|
+
'artifact_dir = "artifacts"\n\n'
|
|
327
|
+
"[[cluster]]\n"
|
|
328
|
+
'name = "local"\n'
|
|
329
|
+
'host = "localhost"\n'
|
|
330
|
+
'scheduler_type = "local"\n'
|
|
331
|
+
'working_dir = "/tmp/researchloop"\n\n'
|
|
332
|
+
"[[study]]\n"
|
|
333
|
+
'name = "my-study"\n'
|
|
334
|
+
'cluster = "local"\n'
|
|
335
|
+
'description = "My research study"\n'
|
|
336
|
+
'sprints_dir = "./sprints"\n'
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
# Create artifact directory.
|
|
340
|
+
artifacts_dir = target / "artifacts"
|
|
341
|
+
artifacts_dir.mkdir(parents=True, exist_ok=True)
|
|
342
|
+
|
|
343
|
+
click.echo(click.style("Initialized ResearchLoop project!", fg="green", bold=True))
|
|
344
|
+
click.echo(f" Config : {config_dest}")
|
|
345
|
+
click.echo(f" Artifacts: {artifacts_dir}")
|
|
346
|
+
click.echo()
|
|
347
|
+
click.echo("Edit researchloop.toml to configure your clusters and studies.")
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
# ---------------------------------------------------------------------------
|
|
351
|
+
# connect / disconnect / status
|
|
352
|
+
# ---------------------------------------------------------------------------
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
@cli.command()
|
|
356
|
+
@click.argument("url", required=False)
|
|
357
|
+
def connect(url: str | None) -> None:
|
|
358
|
+
"""Connect the CLI to a remote ResearchLoop orchestrator.
|
|
359
|
+
|
|
360
|
+
Saves the URL and shared secret to ~/.config/researchloop/credentials.json.
|
|
361
|
+
"""
|
|
362
|
+
from researchloop.core.credentials import (
|
|
363
|
+
load_credentials,
|
|
364
|
+
save_credentials,
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
if not url:
|
|
368
|
+
creds = load_credentials()
|
|
369
|
+
default_url = creds["url"] if creds else None
|
|
370
|
+
url = click.prompt(
|
|
371
|
+
"Orchestrator URL",
|
|
372
|
+
type=str,
|
|
373
|
+
default=default_url,
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
url = (url or "").rstrip("/")
|
|
377
|
+
|
|
378
|
+
password = click.prompt("Password", type=str, hide_input=True)
|
|
379
|
+
|
|
380
|
+
# Authenticate and get an API token.
|
|
381
|
+
try:
|
|
382
|
+
resp = httpx.post(
|
|
383
|
+
f"{url}/api/auth",
|
|
384
|
+
json={"password": password},
|
|
385
|
+
timeout=10,
|
|
386
|
+
)
|
|
387
|
+
if resp.status_code == 401:
|
|
388
|
+
raise click.ClickException("Invalid password.")
|
|
389
|
+
if resp.status_code >= 400:
|
|
390
|
+
raise click.ClickException(
|
|
391
|
+
f"Server error {resp.status_code}: {resp.text[:200]}"
|
|
392
|
+
)
|
|
393
|
+
except httpx.ConnectError:
|
|
394
|
+
raise click.ClickException(f"Cannot connect to {url}")
|
|
395
|
+
except httpx.TimeoutException:
|
|
396
|
+
raise click.ClickException(f"Connection timed out: {url}")
|
|
397
|
+
|
|
398
|
+
token = resp.json()["token"]
|
|
399
|
+
path = save_credentials(url, token)
|
|
400
|
+
click.echo()
|
|
401
|
+
click.echo(click.style("Connected!", fg="green", bold=True) + f" {url}")
|
|
402
|
+
click.echo(click.style(f" Credentials saved to {path}", dim=True))
|
|
403
|
+
click.echo()
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
@cli.command()
|
|
407
|
+
def disconnect() -> None:
|
|
408
|
+
"""Disconnect from the remote orchestrator."""
|
|
409
|
+
from researchloop.core.credentials import clear_credentials
|
|
410
|
+
|
|
411
|
+
clear_credentials()
|
|
412
|
+
click.echo("Disconnected. Credentials removed.")
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
@cli.command()
|
|
416
|
+
def status() -> None:
|
|
417
|
+
"""Show connection status."""
|
|
418
|
+
from researchloop.core.credentials import load_credentials
|
|
419
|
+
|
|
420
|
+
creds = load_credentials()
|
|
421
|
+
if creds:
|
|
422
|
+
click.echo(
|
|
423
|
+
click.style("Connected", fg="green", bold=True) + f" {creds['url']}"
|
|
424
|
+
)
|
|
425
|
+
else:
|
|
426
|
+
click.echo(
|
|
427
|
+
click.style("Not connected", fg="yellow", bold=True)
|
|
428
|
+
+ " Run "
|
|
429
|
+
+ click.style("researchloop connect", bold=True)
|
|
430
|
+
+ " to set up."
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
# ---------------------------------------------------------------------------
|
|
435
|
+
# serve
|
|
436
|
+
# ---------------------------------------------------------------------------
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
@cli.command()
|
|
440
|
+
@click.option("--host", default=None, help="Bind address (overrides config).")
|
|
441
|
+
@click.option("--port", default=None, type=int, help="Bind port (overrides config).")
|
|
442
|
+
@click.pass_context
|
|
443
|
+
def serve(ctx: click.Context, host: str | None, port: int | None) -> None:
|
|
444
|
+
"""Start the ResearchLoop orchestrator server."""
|
|
445
|
+
import uvicorn
|
|
446
|
+
|
|
447
|
+
config = _load_config(ctx.obj.get("config_path"))
|
|
448
|
+
bind_host = host or config.dashboard.host
|
|
449
|
+
bind_port = port or config.dashboard.port
|
|
450
|
+
|
|
451
|
+
click.echo(
|
|
452
|
+
click.style("Starting ResearchLoop server", fg="green", bold=True)
|
|
453
|
+
+ f" on {bind_host}:{bind_port}"
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
uvicorn.run(
|
|
457
|
+
"researchloop.dashboard.app:app",
|
|
458
|
+
host=bind_host,
|
|
459
|
+
port=bind_port,
|
|
460
|
+
reload=False,
|
|
461
|
+
)
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
# ===================================================================
|
|
465
|
+
# study commands
|
|
466
|
+
# ===================================================================
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
@cli.group()
|
|
470
|
+
def study() -> None:
|
|
471
|
+
"""Manage studies."""
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
# -- study list ------------------------------------------------------
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
async def _study_list(config_path: str | None) -> None:
|
|
478
|
+
config = _load_config(config_path)
|
|
479
|
+
db = await _open_db(config)
|
|
480
|
+
try:
|
|
481
|
+
await _ensure_studies_synced(config, db)
|
|
482
|
+
|
|
483
|
+
from researchloop.db import queries
|
|
484
|
+
|
|
485
|
+
studies = await queries.list_studies(db)
|
|
486
|
+
|
|
487
|
+
click.echo(click.style("\nStudies", fg="cyan", bold=True))
|
|
488
|
+
click.echo()
|
|
489
|
+
|
|
490
|
+
rows: list[list[str]] = []
|
|
491
|
+
for s in studies:
|
|
492
|
+
# Count sprints for this study.
|
|
493
|
+
sprints = await queries.list_sprints(db, study_name=s["name"], limit=10000)
|
|
494
|
+
total = len(sprints)
|
|
495
|
+
active = sum(
|
|
496
|
+
1
|
|
497
|
+
for sp in sprints
|
|
498
|
+
if sp["status"] not in ("completed", "failed", "cancelled")
|
|
499
|
+
)
|
|
500
|
+
rows.append(
|
|
501
|
+
[
|
|
502
|
+
click.style(s["name"], fg="white", bold=True),
|
|
503
|
+
s.get("cluster") or "",
|
|
504
|
+
truncate(s.get("description"), 40),
|
|
505
|
+
str(total),
|
|
506
|
+
click.style(str(active), fg="blue") if active else "0",
|
|
507
|
+
]
|
|
508
|
+
)
|
|
509
|
+
|
|
510
|
+
print_table(
|
|
511
|
+
["NAME", "CLUSTER", "DESCRIPTION", "SPRINTS", "ACTIVE"],
|
|
512
|
+
rows,
|
|
513
|
+
)
|
|
514
|
+
click.echo()
|
|
515
|
+
finally:
|
|
516
|
+
await db.close()
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
@study.command("list")
|
|
520
|
+
@click.pass_context
|
|
521
|
+
def study_list(ctx: click.Context) -> None:
|
|
522
|
+
"""List all configured studies."""
|
|
523
|
+
run_async(_study_list(ctx.obj.get("config_path")))
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
# -- study init -----------------------------------------------------
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
_STUDY_CLAUDE_MD_TEMPLATE = """\
|
|
530
|
+
# {name}
|
|
531
|
+
|
|
532
|
+
## Overview
|
|
533
|
+
<!-- Describe your research area. This context is given to Claude at the
|
|
534
|
+
start of every sprint so it understands what you're studying. -->
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
## Background
|
|
538
|
+
<!-- Key papers, prior findings, domain knowledge, or links to resources
|
|
539
|
+
that Claude should be aware of. -->
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
## Codebase
|
|
543
|
+
<!-- Describe any existing code, data formats, or infrastructure the sprint
|
|
544
|
+
should work with. If there's a repo to clone or files to reference,
|
|
545
|
+
mention them here. -->
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
## Goals
|
|
549
|
+
<!-- What are you trying to learn, build, or validate? -->
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
## Constraints
|
|
553
|
+
<!-- Any rules the sprints should follow, e.g. language versions, libraries
|
|
554
|
+
to use or avoid, hardware limitations, output formats. -->
|
|
555
|
+
|
|
556
|
+
"""
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
@study.command("init")
|
|
560
|
+
@click.argument("name")
|
|
561
|
+
@click.option(
|
|
562
|
+
"--dir",
|
|
563
|
+
"directory",
|
|
564
|
+
type=click.Path(),
|
|
565
|
+
default=None,
|
|
566
|
+
help="Directory for study files (default: ./studies/<name>)",
|
|
567
|
+
)
|
|
568
|
+
def study_init(name: str, directory: str | None) -> None:
|
|
569
|
+
"""Scaffold a new study directory with a starter CLAUDE.md."""
|
|
570
|
+
target = Path(directory) if directory else Path("studies") / name
|
|
571
|
+
target = target.resolve()
|
|
572
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
573
|
+
|
|
574
|
+
claude_md = target / "CLAUDE.md"
|
|
575
|
+
if claude_md.exists():
|
|
576
|
+
raise click.ClickException(f"{claude_md} already exists. Edit it directly.")
|
|
577
|
+
|
|
578
|
+
claude_md.write_text(
|
|
579
|
+
_STUDY_CLAUDE_MD_TEMPLATE.format(name=name),
|
|
580
|
+
encoding="utf-8",
|
|
581
|
+
)
|
|
582
|
+
|
|
583
|
+
click.echo(click.style("Created ", fg="green") + str(claude_md))
|
|
584
|
+
click.echo()
|
|
585
|
+
click.echo("Edit this file to describe your research.")
|
|
586
|
+
click.echo(
|
|
587
|
+
"Then set "
|
|
588
|
+
+ click.style("claude_md_path", bold=True)
|
|
589
|
+
+ f' = "{claude_md.relative_to(Path.cwd())}"'
|
|
590
|
+
+ " in your study config."
|
|
591
|
+
)
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
# -- study show ------------------------------------------------------
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
async def _study_show(config_path: str | None, name: str) -> None:
|
|
598
|
+
config = _load_config(config_path)
|
|
599
|
+
db = await _open_db(config)
|
|
600
|
+
try:
|
|
601
|
+
await _ensure_studies_synced(config, db)
|
|
602
|
+
|
|
603
|
+
from researchloop.db import queries
|
|
604
|
+
|
|
605
|
+
study_row = await queries.get_study(db, name)
|
|
606
|
+
if study_row is None:
|
|
607
|
+
raise click.ClickException(f"Study not found: {name}")
|
|
608
|
+
|
|
609
|
+
click.echo()
|
|
610
|
+
click.echo(
|
|
611
|
+
click.style("Study: ", dim=True)
|
|
612
|
+
+ click.style(study_row["name"], fg="cyan", bold=True)
|
|
613
|
+
)
|
|
614
|
+
click.echo(
|
|
615
|
+
click.style(" Cluster : ", dim=True)
|
|
616
|
+
+ (study_row.get("cluster") or "n/a")
|
|
617
|
+
)
|
|
618
|
+
click.echo(
|
|
619
|
+
click.style(" Description: ", dim=True)
|
|
620
|
+
+ (study_row.get("description") or "")
|
|
621
|
+
)
|
|
622
|
+
click.echo(
|
|
623
|
+
click.style(" Sprints dir: ", dim=True)
|
|
624
|
+
+ (study_row.get("sprints_dir") or "")
|
|
625
|
+
)
|
|
626
|
+
click.echo(
|
|
627
|
+
click.style(" CLAUDE.md : ", dim=True)
|
|
628
|
+
+ (study_row.get("claude_md_path") or "")
|
|
629
|
+
)
|
|
630
|
+
click.echo(
|
|
631
|
+
click.style(" Created : ", dim=True)
|
|
632
|
+
+ (study_row.get("created_at") or "")
|
|
633
|
+
)
|
|
634
|
+
|
|
635
|
+
# Show recent sprints.
|
|
636
|
+
sprints = await queries.list_sprints(db, study_name=name, limit=10)
|
|
637
|
+
click.echo()
|
|
638
|
+
click.echo(click.style(" Recent sprints:", bold=True))
|
|
639
|
+
if not sprints:
|
|
640
|
+
click.echo(click.style(" (none)", dim=True))
|
|
641
|
+
else:
|
|
642
|
+
rows = [
|
|
643
|
+
[
|
|
644
|
+
click.style(sp["id"], fg="white", bold=True),
|
|
645
|
+
styled_status(sp["status"]),
|
|
646
|
+
truncate(sp["idea"], 45),
|
|
647
|
+
sp.get("created_at") or "",
|
|
648
|
+
]
|
|
649
|
+
for sp in sprints
|
|
650
|
+
]
|
|
651
|
+
# Indent table.
|
|
652
|
+
headers = ["ID", "STATUS", "IDEA", "CREATED"]
|
|
653
|
+
col_widths = [len(h) for h in headers]
|
|
654
|
+
for row in rows:
|
|
655
|
+
for i, cell in enumerate(row):
|
|
656
|
+
col_widths[i] = max(col_widths[i], len(click.unstyle(cell)))
|
|
657
|
+
|
|
658
|
+
click.echo(
|
|
659
|
+
" "
|
|
660
|
+
+ " ".join(h.ljust(col_widths[i]) for i, h in enumerate(headers))
|
|
661
|
+
)
|
|
662
|
+
click.echo(" " + " ".join("-" * w for w in col_widths))
|
|
663
|
+
for row in rows:
|
|
664
|
+
click.echo(
|
|
665
|
+
" "
|
|
666
|
+
+ " ".join(
|
|
667
|
+
cell + " " * (col_widths[i] - len(click.unstyle(cell)))
|
|
668
|
+
for i, cell in enumerate(row)
|
|
669
|
+
)
|
|
670
|
+
)
|
|
671
|
+
click.echo()
|
|
672
|
+
finally:
|
|
673
|
+
await db.close()
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
@study.command("show")
|
|
677
|
+
@click.argument("name")
|
|
678
|
+
@click.pass_context
|
|
679
|
+
def study_show(ctx: click.Context, name: str) -> None:
|
|
680
|
+
"""Show details of a study."""
|
|
681
|
+
run_async(_study_show(ctx.obj.get("config_path"), name))
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
# ===================================================================
|
|
685
|
+
# sprint commands
|
|
686
|
+
# ===================================================================
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
@cli.group()
|
|
690
|
+
def sprint() -> None:
|
|
691
|
+
"""Manage sprints."""
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
# -- sprint run -------------------------------------------------------
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
def _sprint_run(config_path: str | None, study_name: str, idea: str) -> None:
|
|
698
|
+
config = _try_load_config(config_path)
|
|
699
|
+
result = _api_post(
|
|
700
|
+
config,
|
|
701
|
+
"/api/sprints",
|
|
702
|
+
{"study_name": study_name, "idea": idea},
|
|
703
|
+
)
|
|
704
|
+
|
|
705
|
+
click.echo()
|
|
706
|
+
click.echo(click.style("Sprint submitted!", fg="green", bold=True))
|
|
707
|
+
click.echo(
|
|
708
|
+
click.style(" ID : ", dim=True)
|
|
709
|
+
+ click.style(result["sprint_id"], fg="cyan", bold=True)
|
|
710
|
+
)
|
|
711
|
+
click.echo(click.style(" Study : ", dim=True) + study_name)
|
|
712
|
+
click.echo(click.style(" Idea : ", dim=True) + idea)
|
|
713
|
+
click.echo(
|
|
714
|
+
click.style(" Status: ", dim=True)
|
|
715
|
+
+ styled_status(result.get("status", "submitted"))
|
|
716
|
+
)
|
|
717
|
+
click.echo()
|
|
718
|
+
|
|
719
|
+
|
|
720
|
+
@sprint.command("run")
|
|
721
|
+
@click.argument("idea")
|
|
722
|
+
@click.option(
|
|
723
|
+
"--study",
|
|
724
|
+
"-s",
|
|
725
|
+
"study_name",
|
|
726
|
+
required=True,
|
|
727
|
+
help="Study name",
|
|
728
|
+
)
|
|
729
|
+
@click.pass_context
|
|
730
|
+
def sprint_run(ctx: click.Context, idea: str, study_name: str) -> None:
|
|
731
|
+
"""Submit a new sprint with the given idea."""
|
|
732
|
+
_sprint_run(ctx.obj.get("config_path"), study_name, idea)
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
# -- sprint list ------------------------------------------------------
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
async def _sprint_list(
|
|
739
|
+
config_path: str | None,
|
|
740
|
+
study_name: str | None,
|
|
741
|
+
limit: int,
|
|
742
|
+
) -> None:
|
|
743
|
+
config = _load_config(config_path)
|
|
744
|
+
db = await _open_db(config)
|
|
745
|
+
try:
|
|
746
|
+
await _ensure_studies_synced(config, db)
|
|
747
|
+
|
|
748
|
+
from researchloop.db import queries
|
|
749
|
+
|
|
750
|
+
sprints = await queries.list_sprints(db, study_name=study_name, limit=limit)
|
|
751
|
+
|
|
752
|
+
title = "Sprints"
|
|
753
|
+
if study_name:
|
|
754
|
+
title += f" (study: {study_name})"
|
|
755
|
+
|
|
756
|
+
click.echo()
|
|
757
|
+
click.echo(click.style(title, fg="cyan", bold=True))
|
|
758
|
+
click.echo()
|
|
759
|
+
|
|
760
|
+
rows = [
|
|
761
|
+
[
|
|
762
|
+
click.style(sp["id"], fg="white", bold=True),
|
|
763
|
+
sp.get("study_name") or "",
|
|
764
|
+
styled_status(sp["status"]),
|
|
765
|
+
truncate(sp["idea"], 40),
|
|
766
|
+
sp.get("created_at") or "",
|
|
767
|
+
]
|
|
768
|
+
for sp in sprints
|
|
769
|
+
]
|
|
770
|
+
|
|
771
|
+
print_table(["ID", "STUDY", "STATUS", "IDEA", "CREATED"], rows)
|
|
772
|
+
click.echo()
|
|
773
|
+
finally:
|
|
774
|
+
await db.close()
|
|
775
|
+
|
|
776
|
+
|
|
777
|
+
@sprint.command("list")
|
|
778
|
+
@click.option("--study", "-s", "study_name", default=None, help="Filter by study name")
|
|
779
|
+
@click.option("--limit", "-n", default=20, type=int, help="Max sprints to show")
|
|
780
|
+
@click.pass_context
|
|
781
|
+
def sprint_list(ctx: click.Context, study_name: str | None, limit: int) -> None:
|
|
782
|
+
"""List sprints."""
|
|
783
|
+
run_async(_sprint_list(ctx.obj.get("config_path"), study_name, limit))
|
|
784
|
+
|
|
785
|
+
|
|
786
|
+
# -- sprint show ------------------------------------------------------
|
|
787
|
+
|
|
788
|
+
|
|
789
|
+
async def _sprint_show(config_path: str | None, sprint_id: str) -> None:
|
|
790
|
+
config = _load_config(config_path)
|
|
791
|
+
db = await _open_db(config)
|
|
792
|
+
try:
|
|
793
|
+
from researchloop.db import queries
|
|
794
|
+
|
|
795
|
+
sp = await queries.get_sprint(db, sprint_id)
|
|
796
|
+
if sp is None:
|
|
797
|
+
raise click.ClickException(f"Sprint not found: {sprint_id}")
|
|
798
|
+
|
|
799
|
+
click.echo()
|
|
800
|
+
click.echo(
|
|
801
|
+
click.style("Sprint: ", dim=True)
|
|
802
|
+
+ click.style(sp["id"], fg="cyan", bold=True)
|
|
803
|
+
)
|
|
804
|
+
click.echo(
|
|
805
|
+
click.style(" Study : ", dim=True) + (sp.get("study_name") or "")
|
|
806
|
+
)
|
|
807
|
+
click.echo(click.style(" Idea : ", dim=True) + (sp.get("idea") or ""))
|
|
808
|
+
click.echo(click.style(" Status : ", dim=True) + styled_status(sp["status"]))
|
|
809
|
+
click.echo(click.style(" Job ID : ", dim=True) + (sp.get("job_id") or "n/a"))
|
|
810
|
+
click.echo(click.style(" Directory: ", dim=True) + (sp.get("directory") or ""))
|
|
811
|
+
click.echo(
|
|
812
|
+
click.style(" Created : ", dim=True) + (sp.get("created_at") or "")
|
|
813
|
+
)
|
|
814
|
+
click.echo(
|
|
815
|
+
click.style(" Started : ", dim=True) + (sp.get("started_at") or "n/a")
|
|
816
|
+
)
|
|
817
|
+
click.echo(
|
|
818
|
+
click.style(" Completed: ", dim=True) + (sp.get("completed_at") or "n/a")
|
|
819
|
+
)
|
|
820
|
+
|
|
821
|
+
if sp.get("error"):
|
|
822
|
+
click.echo(click.style(" Error : ", fg="red", bold=True) + sp["error"])
|
|
823
|
+
|
|
824
|
+
if sp.get("summary"):
|
|
825
|
+
click.echo()
|
|
826
|
+
click.echo(click.style(" Summary:", bold=True))
|
|
827
|
+
for line in sp["summary"].splitlines():
|
|
828
|
+
click.echo(f" {line}")
|
|
829
|
+
|
|
830
|
+
# Artifacts.
|
|
831
|
+
artifacts = await queries.list_artifacts(db, sprint_id)
|
|
832
|
+
click.echo()
|
|
833
|
+
click.echo(click.style(" Artifacts:", bold=True))
|
|
834
|
+
if not artifacts:
|
|
835
|
+
click.echo(click.style(" (none)", dim=True))
|
|
836
|
+
else:
|
|
837
|
+
for art in artifacts:
|
|
838
|
+
size_str = ""
|
|
839
|
+
if art.get("size"):
|
|
840
|
+
size_kb = art["size"] / 1024
|
|
841
|
+
if size_kb > 1024:
|
|
842
|
+
size_str = f" ({size_kb / 1024:.1f} MB)"
|
|
843
|
+
else:
|
|
844
|
+
size_str = f" ({size_kb:.1f} KB)"
|
|
845
|
+
click.echo(
|
|
846
|
+
f" - {art['filename']}{size_str}"
|
|
847
|
+
+ click.style(f" [{art['path']}]", dim=True)
|
|
848
|
+
)
|
|
849
|
+
click.echo()
|
|
850
|
+
finally:
|
|
851
|
+
await db.close()
|
|
852
|
+
|
|
853
|
+
|
|
854
|
+
@sprint.command("show")
|
|
855
|
+
@click.argument("sprint_id")
|
|
856
|
+
@click.pass_context
|
|
857
|
+
def sprint_show(ctx: click.Context, sprint_id: str) -> None:
|
|
858
|
+
"""Show details of a sprint."""
|
|
859
|
+
run_async(_sprint_show(ctx.obj.get("config_path"), sprint_id))
|
|
860
|
+
|
|
861
|
+
|
|
862
|
+
# -- sprint cancel -----------------------------------------------------
|
|
863
|
+
|
|
864
|
+
|
|
865
|
+
def _sprint_cancel(config_path: str | None, sprint_id: str) -> None:
|
|
866
|
+
config = _try_load_config(config_path)
|
|
867
|
+
_api_post(config, f"/api/sprints/{sprint_id}/cancel")
|
|
868
|
+
|
|
869
|
+
click.echo(
|
|
870
|
+
click.style("Cancelled", fg="yellow", bold=True)
|
|
871
|
+
+ f" sprint {click.style(sprint_id, fg='cyan', bold=True)}"
|
|
872
|
+
)
|
|
873
|
+
|
|
874
|
+
|
|
875
|
+
@sprint.command("cancel")
|
|
876
|
+
@click.argument("sprint_id")
|
|
877
|
+
@click.pass_context
|
|
878
|
+
def sprint_cancel(ctx: click.Context, sprint_id: str) -> None:
|
|
879
|
+
"""Cancel a running sprint."""
|
|
880
|
+
_sprint_cancel(ctx.obj.get("config_path"), sprint_id)
|
|
881
|
+
|
|
882
|
+
|
|
883
|
+
# ===================================================================
|
|
884
|
+
# loop commands
|
|
885
|
+
# ===================================================================
|
|
886
|
+
|
|
887
|
+
|
|
888
|
+
@cli.group()
|
|
889
|
+
def loop() -> None:
|
|
890
|
+
"""Manage auto-loops."""
|
|
891
|
+
|
|
892
|
+
|
|
893
|
+
# -- loop start -------------------------------------------------------
|
|
894
|
+
|
|
895
|
+
|
|
896
|
+
def _loop_start(
|
|
897
|
+
config_path: str | None,
|
|
898
|
+
study_name: str,
|
|
899
|
+
count: int,
|
|
900
|
+
context: str,
|
|
901
|
+
) -> None:
|
|
902
|
+
config = _try_load_config(config_path)
|
|
903
|
+
body: dict[str, Any] = {
|
|
904
|
+
"study_name": study_name,
|
|
905
|
+
"count": count,
|
|
906
|
+
}
|
|
907
|
+
if context:
|
|
908
|
+
body["context"] = context
|
|
909
|
+
result = _api_post(config, "/api/loops", body)
|
|
910
|
+
|
|
911
|
+
click.echo()
|
|
912
|
+
click.echo(click.style("Auto-loop started!", fg="green", bold=True))
|
|
913
|
+
click.echo(
|
|
914
|
+
click.style(" ID : ", dim=True)
|
|
915
|
+
+ click.style(result["loop_id"], fg="cyan", bold=True)
|
|
916
|
+
)
|
|
917
|
+
click.echo(click.style(" Study : ", dim=True) + study_name)
|
|
918
|
+
click.echo(click.style(" Count : ", dim=True) + str(count))
|
|
919
|
+
if context:
|
|
920
|
+
click.echo(click.style(" Context: ", dim=True) + context[:80])
|
|
921
|
+
click.echo()
|
|
922
|
+
|
|
923
|
+
|
|
924
|
+
@loop.command("start")
|
|
925
|
+
@click.option(
|
|
926
|
+
"--study",
|
|
927
|
+
"-s",
|
|
928
|
+
"study_name",
|
|
929
|
+
required=True,
|
|
930
|
+
help="Study name",
|
|
931
|
+
)
|
|
932
|
+
@click.option(
|
|
933
|
+
"--count",
|
|
934
|
+
"-n",
|
|
935
|
+
default=3,
|
|
936
|
+
type=int,
|
|
937
|
+
help="Number of sprints to run",
|
|
938
|
+
)
|
|
939
|
+
@click.option(
|
|
940
|
+
"--context",
|
|
941
|
+
"-m",
|
|
942
|
+
default="",
|
|
943
|
+
help="Guidance for the idea generator (e.g. topics, paper links)",
|
|
944
|
+
)
|
|
945
|
+
@click.pass_context
|
|
946
|
+
def loop_start(
|
|
947
|
+
ctx: click.Context,
|
|
948
|
+
study_name: str,
|
|
949
|
+
count: int,
|
|
950
|
+
context: str,
|
|
951
|
+
) -> None:
|
|
952
|
+
"""Start an auto-loop."""
|
|
953
|
+
_loop_start(ctx.obj.get("config_path"), study_name, count, context)
|
|
954
|
+
|
|
955
|
+
|
|
956
|
+
# -- loop status -------------------------------------------------------
|
|
957
|
+
|
|
958
|
+
|
|
959
|
+
async def _loop_status(config_path: str | None) -> None:
|
|
960
|
+
config = _load_config(config_path)
|
|
961
|
+
db = await _open_db(config)
|
|
962
|
+
try:
|
|
963
|
+
from researchloop.db import queries
|
|
964
|
+
|
|
965
|
+
loops = await queries.list_auto_loops(db)
|
|
966
|
+
|
|
967
|
+
click.echo()
|
|
968
|
+
click.echo(click.style("Auto-Loops", fg="cyan", bold=True))
|
|
969
|
+
click.echo()
|
|
970
|
+
|
|
971
|
+
rows = [
|
|
972
|
+
[
|
|
973
|
+
click.style(lp["id"], fg="white", bold=True),
|
|
974
|
+
lp.get("study_name") or "",
|
|
975
|
+
styled_status(lp["status"]),
|
|
976
|
+
f"{lp.get('completed_count', 0)}/{lp.get('total_count', 0)}",
|
|
977
|
+
lp.get("current_sprint_id") or "n/a",
|
|
978
|
+
lp.get("created_at") or "",
|
|
979
|
+
]
|
|
980
|
+
for lp in loops
|
|
981
|
+
]
|
|
982
|
+
|
|
983
|
+
print_table(
|
|
984
|
+
["ID", "STUDY", "STATUS", "PROGRESS", "CURRENT SPRINT", "CREATED"],
|
|
985
|
+
rows,
|
|
986
|
+
)
|
|
987
|
+
click.echo()
|
|
988
|
+
finally:
|
|
989
|
+
await db.close()
|
|
990
|
+
|
|
991
|
+
|
|
992
|
+
@loop.command("status")
|
|
993
|
+
@click.pass_context
|
|
994
|
+
def loop_status(ctx: click.Context) -> None:
|
|
995
|
+
"""Show auto-loop status."""
|
|
996
|
+
run_async(_loop_status(ctx.obj.get("config_path")))
|
|
997
|
+
|
|
998
|
+
|
|
999
|
+
# -- loop stop ---------------------------------------------------------
|
|
1000
|
+
|
|
1001
|
+
|
|
1002
|
+
async def _loop_stop(config_path: str | None, loop_id: str) -> None:
|
|
1003
|
+
config = _load_config(config_path)
|
|
1004
|
+
db = await _open_db(config)
|
|
1005
|
+
try:
|
|
1006
|
+
from researchloop.db import queries
|
|
1007
|
+
|
|
1008
|
+
lp = await queries.get_auto_loop(db, loop_id)
|
|
1009
|
+
if lp is None:
|
|
1010
|
+
raise click.ClickException(f"Auto-loop not found: {loop_id}")
|
|
1011
|
+
|
|
1012
|
+
if lp["status"] not in ("running", "pending"):
|
|
1013
|
+
raise click.ClickException(
|
|
1014
|
+
f"Auto-loop {loop_id} is already {lp['status']}; cannot stop."
|
|
1015
|
+
)
|
|
1016
|
+
|
|
1017
|
+
await queries.update_auto_loop(db, loop_id, status="stopped")
|
|
1018
|
+
|
|
1019
|
+
click.echo(
|
|
1020
|
+
click.style("Stopped", fg="yellow", bold=True)
|
|
1021
|
+
+ f" auto-loop {click.style(loop_id, fg='cyan', bold=True)}"
|
|
1022
|
+
)
|
|
1023
|
+
finally:
|
|
1024
|
+
await db.close()
|
|
1025
|
+
|
|
1026
|
+
|
|
1027
|
+
@loop.command("stop")
|
|
1028
|
+
@click.argument("loop_id")
|
|
1029
|
+
@click.pass_context
|
|
1030
|
+
def loop_stop(ctx: click.Context, loop_id: str) -> None:
|
|
1031
|
+
"""Stop an auto-loop."""
|
|
1032
|
+
run_async(_loop_stop(ctx.obj.get("config_path"), loop_id))
|
|
1033
|
+
|
|
1034
|
+
|
|
1035
|
+
# ===================================================================
|
|
1036
|
+
# cluster commands
|
|
1037
|
+
# ===================================================================
|
|
1038
|
+
|
|
1039
|
+
|
|
1040
|
+
@cli.group()
|
|
1041
|
+
def cluster() -> None:
|
|
1042
|
+
"""Manage clusters."""
|
|
1043
|
+
|
|
1044
|
+
|
|
1045
|
+
# -- cluster list ------------------------------------------------------
|
|
1046
|
+
|
|
1047
|
+
|
|
1048
|
+
@cluster.command("list")
|
|
1049
|
+
@click.pass_context
|
|
1050
|
+
def cluster_list(ctx: click.Context) -> None:
|
|
1051
|
+
"""List configured clusters."""
|
|
1052
|
+
config = _load_config(ctx.obj.get("config_path"))
|
|
1053
|
+
|
|
1054
|
+
click.echo()
|
|
1055
|
+
click.echo(click.style("Clusters", fg="cyan", bold=True))
|
|
1056
|
+
click.echo()
|
|
1057
|
+
|
|
1058
|
+
rows = [
|
|
1059
|
+
[
|
|
1060
|
+
click.style(c.name, fg="white", bold=True),
|
|
1061
|
+
f"{c.host}:{c.port}",
|
|
1062
|
+
c.user or "n/a",
|
|
1063
|
+
c.scheduler_type,
|
|
1064
|
+
str(c.max_concurrent_jobs),
|
|
1065
|
+
c.working_dir or "n/a",
|
|
1066
|
+
]
|
|
1067
|
+
for c in config.clusters
|
|
1068
|
+
]
|
|
1069
|
+
|
|
1070
|
+
print_table(
|
|
1071
|
+
["NAME", "HOST", "USER", "SCHEDULER", "MAX JOBS", "WORKING DIR"],
|
|
1072
|
+
rows,
|
|
1073
|
+
)
|
|
1074
|
+
click.echo()
|
|
1075
|
+
|
|
1076
|
+
|
|
1077
|
+
# -- cluster check -----------------------------------------------------
|
|
1078
|
+
|
|
1079
|
+
|
|
1080
|
+
async def _cluster_check(config_path: str | None, cluster_name: str | None) -> None:
|
|
1081
|
+
config = _load_config(config_path)
|
|
1082
|
+
|
|
1083
|
+
targets = config.clusters
|
|
1084
|
+
if cluster_name:
|
|
1085
|
+
targets = [c for c in targets if c.name == cluster_name]
|
|
1086
|
+
if not targets:
|
|
1087
|
+
raise click.ClickException(
|
|
1088
|
+
f"Cluster not found: {cluster_name}. "
|
|
1089
|
+
"Run 'researchloop cluster list' to see available clusters."
|
|
1090
|
+
)
|
|
1091
|
+
|
|
1092
|
+
click.echo()
|
|
1093
|
+
click.echo(click.style("Cluster connectivity check", fg="cyan", bold=True))
|
|
1094
|
+
click.echo()
|
|
1095
|
+
|
|
1096
|
+
from researchloop.clusters.ssh import SSHConnection
|
|
1097
|
+
|
|
1098
|
+
for c in targets:
|
|
1099
|
+
label = click.style(c.name, fg="white", bold=True)
|
|
1100
|
+
click.echo(f" {label} ({c.user}@{c.host}:{c.port}) ... ", nl=False)
|
|
1101
|
+
|
|
1102
|
+
try:
|
|
1103
|
+
conn = SSHConnection(
|
|
1104
|
+
host=c.host,
|
|
1105
|
+
port=c.port,
|
|
1106
|
+
user=c.user,
|
|
1107
|
+
key_path=c.key_path,
|
|
1108
|
+
)
|
|
1109
|
+
await conn.connect()
|
|
1110
|
+
stdout, _stderr, exit_code = await conn.run("hostname", timeout=10)
|
|
1111
|
+
await conn.close()
|
|
1112
|
+
|
|
1113
|
+
if exit_code == 0:
|
|
1114
|
+
hostname = stdout.strip()
|
|
1115
|
+
click.echo(
|
|
1116
|
+
click.style("OK", fg="green", bold=True)
|
|
1117
|
+
+ click.style(f" (hostname: {hostname})", dim=True)
|
|
1118
|
+
)
|
|
1119
|
+
else:
|
|
1120
|
+
click.echo(
|
|
1121
|
+
click.style("WARN", fg="yellow", bold=True)
|
|
1122
|
+
+ f" (exit code {exit_code})"
|
|
1123
|
+
)
|
|
1124
|
+
except Exception as exc:
|
|
1125
|
+
click.echo(
|
|
1126
|
+
click.style("FAIL", fg="red", bold=True)
|
|
1127
|
+
+ click.style(f" ({exc})", dim=True)
|
|
1128
|
+
)
|
|
1129
|
+
|
|
1130
|
+
click.echo()
|
|
1131
|
+
|
|
1132
|
+
|
|
1133
|
+
@cluster.command("check")
|
|
1134
|
+
@click.option("--name", "-n", default=None, help="Check a specific cluster")
|
|
1135
|
+
@click.pass_context
|
|
1136
|
+
def cluster_check(ctx: click.Context, name: str | None) -> None:
|
|
1137
|
+
"""Check cluster connectivity."""
|
|
1138
|
+
run_async(_cluster_check(ctx.obj.get("config_path"), name))
|