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.
Files changed (63) hide show
  1. researchloop/__init__.py +1 -0
  2. researchloop/__main__.py +3 -0
  3. researchloop/cli.py +1138 -0
  4. researchloop/clusters/__init__.py +4 -0
  5. researchloop/clusters/monitor.py +199 -0
  6. researchloop/clusters/ssh.py +183 -0
  7. researchloop/comms/__init__.py +0 -0
  8. researchloop/comms/base.py +34 -0
  9. researchloop/comms/conversation.py +465 -0
  10. researchloop/comms/ntfy.py +95 -0
  11. researchloop/comms/router.py +71 -0
  12. researchloop/comms/slack.py +188 -0
  13. researchloop/core/__init__.py +0 -0
  14. researchloop/core/auth.py +78 -0
  15. researchloop/core/config.py +328 -0
  16. researchloop/core/credentials.py +38 -0
  17. researchloop/core/models.py +119 -0
  18. researchloop/core/orchestrator.py +910 -0
  19. researchloop/dashboard/__init__.py +0 -0
  20. researchloop/dashboard/app.py +15 -0
  21. researchloop/dashboard/auth.py +60 -0
  22. researchloop/dashboard/routes.py +912 -0
  23. researchloop/dashboard/templates/base.html +84 -0
  24. researchloop/dashboard/templates/login.html +12 -0
  25. researchloop/dashboard/templates/loop_detail.html +58 -0
  26. researchloop/dashboard/templates/loops.html +61 -0
  27. researchloop/dashboard/templates/setup.html +14 -0
  28. researchloop/dashboard/templates/sprint_detail.html +109 -0
  29. researchloop/dashboard/templates/sprints.html +48 -0
  30. researchloop/dashboard/templates/studies.html +18 -0
  31. researchloop/dashboard/templates/study_detail.html +64 -0
  32. researchloop/db/__init__.py +5 -0
  33. researchloop/db/database.py +86 -0
  34. researchloop/db/migrations.py +172 -0
  35. researchloop/db/queries.py +351 -0
  36. researchloop/runner/__init__.py +1 -0
  37. researchloop/runner/claude.py +169 -0
  38. researchloop/runner/job_templates/sge.sh.j2 +319 -0
  39. researchloop/runner/job_templates/slurm.sh.j2 +336 -0
  40. researchloop/runner/main.py +156 -0
  41. researchloop/runner/pipeline.py +272 -0
  42. researchloop/runner/templates/fix_issues.md.j2 +11 -0
  43. researchloop/runner/templates/idea_generator.md.j2 +16 -0
  44. researchloop/runner/templates/red_team.md.j2 +15 -0
  45. researchloop/runner/templates/report.md.j2 +31 -0
  46. researchloop/runner/templates/research_sprint.md.j2 +51 -0
  47. researchloop/runner/templates/summarizer.md.j2 +7 -0
  48. researchloop/runner/upload.py +153 -0
  49. researchloop/schedulers/__init__.py +11 -0
  50. researchloop/schedulers/base.py +43 -0
  51. researchloop/schedulers/local.py +188 -0
  52. researchloop/schedulers/sge.py +163 -0
  53. researchloop/schedulers/slurm.py +179 -0
  54. researchloop/sprints/__init__.py +0 -0
  55. researchloop/sprints/auto_loop.py +458 -0
  56. researchloop/sprints/manager.py +750 -0
  57. researchloop/studies/__init__.py +0 -0
  58. researchloop/studies/manager.py +102 -0
  59. researchloop-0.1.0.dist-info/METADATA +596 -0
  60. researchloop-0.1.0.dist-info/RECORD +63 -0
  61. researchloop-0.1.0.dist-info/WHEEL +4 -0
  62. researchloop-0.1.0.dist-info/entry_points.txt +3 -0
  63. 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))