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
@@ -0,0 +1,153 @@
1
+ """Artifact upload and orchestrator webhooks.
2
+
3
+ Uses ``httpx`` for HTTP calls since this runs on HPC nodes where
4
+ ``aiohttp`` may not be available.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ import mimetypes
11
+ from pathlib import Path
12
+
13
+ import httpx
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ # Files we always look for in the sprint directory.
18
+ _KEY_FILENAMES = {"report.md", "summary.txt"}
19
+
20
+ # Extensions we scan for as additional artifacts.
21
+ _ARTIFACT_EXTENSIONS = {".pdf", ".png", ".jpg", ".jpeg"}
22
+
23
+ # Generous timeout for large uploads from HPC → orchestrator.
24
+ _UPLOAD_TIMEOUT = httpx.Timeout(connect=10.0, read=60.0, write=120.0, pool=10.0)
25
+ _WEBHOOK_TIMEOUT = httpx.Timeout(connect=10.0, read=30.0, write=30.0, pool=10.0)
26
+
27
+
28
+ def _auth_headers(shared_secret: str) -> dict[str, str]:
29
+ return {"Authorization": f"Bearer {shared_secret}"}
30
+
31
+
32
+ async def upload_artifacts(
33
+ sprint_dir: str,
34
+ orchestrator_url: str,
35
+ shared_secret: str,
36
+ sprint_id: str,
37
+ ) -> list[str]:
38
+ """Scan *sprint_dir* for key artifacts and upload them to the orchestrator.
39
+
40
+ Returns a list of successfully uploaded filenames.
41
+ """
42
+ sprint_path = Path(sprint_dir)
43
+ files_to_upload: list[Path] = []
44
+
45
+ # Collect known filenames.
46
+ for name in _KEY_FILENAMES:
47
+ candidate = sprint_path / name
48
+ if candidate.is_file():
49
+ files_to_upload.append(candidate)
50
+
51
+ # Scan for image / PDF artifacts anywhere in the sprint directory (1 level deep).
52
+ for child in sprint_path.iterdir():
53
+ if child.is_file() and child.suffix.lower() in _ARTIFACT_EXTENSIONS:
54
+ if child not in files_to_upload:
55
+ files_to_upload.append(child)
56
+
57
+ # Also check results/ subdirectory.
58
+ results_dir = sprint_path / "results"
59
+ if results_dir.is_dir():
60
+ for child in results_dir.iterdir():
61
+ if child.is_file() and child.suffix.lower() in _ARTIFACT_EXTENSIONS:
62
+ files_to_upload.append(child)
63
+
64
+ if not files_to_upload:
65
+ logger.info("No artifacts found to upload in %s", sprint_dir)
66
+ return []
67
+
68
+ url = f"{orchestrator_url.rstrip('/')}/api/artifacts/{sprint_id}"
69
+ uploaded: list[str] = []
70
+
71
+ async with httpx.AsyncClient(timeout=_UPLOAD_TIMEOUT) as client:
72
+ for file_path in files_to_upload:
73
+ mime_type = (
74
+ mimetypes.guess_type(str(file_path))[0] or "application/octet-stream"
75
+ )
76
+ try:
77
+ with open(file_path, "rb") as f:
78
+ resp = await client.post(
79
+ url,
80
+ headers=_auth_headers(shared_secret),
81
+ files={"file": (file_path.name, f, mime_type)},
82
+ )
83
+ resp.raise_for_status()
84
+ uploaded.append(file_path.name)
85
+ logger.info("Uploaded artifact: %s", file_path.name)
86
+ except httpx.HTTPError:
87
+ logger.exception("Failed to upload artifact: %s", file_path.name)
88
+
89
+ return uploaded
90
+
91
+
92
+ async def send_webhook(
93
+ orchestrator_url: str,
94
+ shared_secret: str,
95
+ sprint_id: str,
96
+ status: str,
97
+ summary: str | None = None,
98
+ error: str | None = None,
99
+ ) -> None:
100
+ """Notify the orchestrator that a sprint has completed (or failed).
101
+
102
+ POSTs to ``/api/webhook/sprint-complete``.
103
+ """
104
+ url = f"{orchestrator_url.rstrip('/')}/api/webhook/sprint-complete"
105
+ payload = {
106
+ "sprint_id": sprint_id,
107
+ "status": status,
108
+ "summary": summary,
109
+ "error": error,
110
+ }
111
+
112
+ async with httpx.AsyncClient(timeout=_WEBHOOK_TIMEOUT) as client:
113
+ resp = await client.post(
114
+ url,
115
+ headers={
116
+ **_auth_headers(shared_secret),
117
+ "Content-Type": "application/json",
118
+ },
119
+ json=payload,
120
+ )
121
+ resp.raise_for_status()
122
+
123
+ logger.info("Sent sprint-complete webhook: sprint=%s status=%s", sprint_id, status)
124
+
125
+
126
+ async def send_heartbeat(
127
+ orchestrator_url: str,
128
+ shared_secret: str,
129
+ sprint_id: str,
130
+ status: str,
131
+ step: int,
132
+ ) -> None:
133
+ """Send a heartbeat ping to the orchestrator.
134
+
135
+ POSTs to ``/api/webhook/heartbeat``.
136
+ """
137
+ url = f"{orchestrator_url.rstrip('/')}/api/webhook/heartbeat"
138
+ payload = {
139
+ "sprint_id": sprint_id,
140
+ "status": status,
141
+ "step": step,
142
+ }
143
+
144
+ async with httpx.AsyncClient(timeout=_WEBHOOK_TIMEOUT) as client:
145
+ resp = await client.post(
146
+ url,
147
+ headers={
148
+ **_auth_headers(shared_secret),
149
+ "Content-Type": "application/json",
150
+ },
151
+ json=payload,
152
+ )
153
+ resp.raise_for_status()
@@ -0,0 +1,11 @@
1
+ from researchloop.schedulers.base import BaseScheduler
2
+ from researchloop.schedulers.local import LocalScheduler
3
+ from researchloop.schedulers.sge import SGEScheduler
4
+ from researchloop.schedulers.slurm import SlurmScheduler
5
+
6
+ __all__ = [
7
+ "BaseScheduler",
8
+ "SGEScheduler",
9
+ "SlurmScheduler",
10
+ "LocalScheduler",
11
+ ]
@@ -0,0 +1,43 @@
1
+ """Abstract base class for job schedulers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+
7
+
8
+ class BaseScheduler(ABC):
9
+ """Interface that every scheduler backend must implement."""
10
+
11
+ @abstractmethod
12
+ async def submit(
13
+ self,
14
+ ssh: object,
15
+ script: str,
16
+ job_name: str,
17
+ working_dir: str,
18
+ env: dict[str, str] | None = None,
19
+ ) -> str:
20
+ """Submit a job and return the scheduler-assigned job ID."""
21
+
22
+ @abstractmethod
23
+ async def status(self, ssh: object, job_id: str) -> str:
24
+ """Return the current status of a job.
25
+
26
+ Must return one of:
27
+ ``"pending"``, ``"running"``, ``"completed"``, ``"failed"``, ``"unknown"``.
28
+ """
29
+
30
+ @abstractmethod
31
+ async def cancel(self, ssh: object, job_id: str) -> bool:
32
+ """Cancel a job. Return ``True`` on success."""
33
+
34
+ @abstractmethod
35
+ def generate_script(
36
+ self,
37
+ command: str,
38
+ job_name: str,
39
+ working_dir: str,
40
+ time_limit: str = "8:00:00",
41
+ env: dict[str, str] | None = None,
42
+ ) -> str:
43
+ """Generate the contents of a submission script."""
@@ -0,0 +1,188 @@
1
+ """Local subprocess scheduler for testing without a real cluster."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ import os
8
+ import signal
9
+ import textwrap
10
+
11
+ from researchloop.schedulers.base import BaseScheduler
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ class LocalScheduler(BaseScheduler):
17
+ """Scheduler that runs commands as local subprocesses.
18
+
19
+ Intended for development and testing. Job IDs are OS process IDs (PIDs)
20
+ stored as strings for consistency with the ``BaseScheduler`` interface.
21
+ The *ssh* parameter is accepted but ignored -- all commands run locally.
22
+ """
23
+
24
+ def __init__(self) -> None:
25
+ # Track running processes keyed by PID string.
26
+ self._processes: dict[str, asyncio.subprocess.Process] = {}
27
+
28
+ # ------------------------------------------------------------------
29
+ # submit
30
+ # ------------------------------------------------------------------
31
+
32
+ async def submit(
33
+ self,
34
+ ssh: object,
35
+ script: str,
36
+ job_name: str,
37
+ working_dir: str,
38
+ env: dict[str, str] | None = None,
39
+ ) -> str:
40
+ """Run *script* as a local background subprocess.
41
+
42
+ Returns the PID of the spawned process as a string.
43
+ """
44
+ # Build the environment for the subprocess.
45
+ proc_env = os.environ.copy()
46
+ if env:
47
+ proc_env.update(env)
48
+
49
+ # Ensure the working directory exists.
50
+ os.makedirs(working_dir, exist_ok=True)
51
+
52
+ # Write the script to a temporary file in working_dir so it can
53
+ # be executed as a proper bash script.
54
+ script_path = os.path.join(working_dir, f".researchloop_local_{job_name}.sh")
55
+ with open(script_path, "w") as f:
56
+ f.write(script)
57
+ os.chmod(script_path, 0o755)
58
+
59
+ stdout_path = os.path.join(working_dir, f"{job_name}.out")
60
+ stderr_path = os.path.join(working_dir, f"{job_name}.err")
61
+
62
+ stdout_file = open(stdout_path, "w")
63
+ stderr_file = open(stderr_path, "w")
64
+
65
+ proc = await asyncio.create_subprocess_exec(
66
+ "bash",
67
+ script_path,
68
+ cwd=working_dir,
69
+ env=proc_env,
70
+ stdout=stdout_file,
71
+ stderr=stderr_file,
72
+ start_new_session=True,
73
+ )
74
+
75
+ stdout_file.close()
76
+ stderr_file.close()
77
+
78
+ job_id = str(proc.pid)
79
+ self._processes[job_id] = proc
80
+
81
+ logger.info(
82
+ "Local job submitted: pid=%s name=%s dir=%s", job_id, job_name, working_dir
83
+ )
84
+ return job_id
85
+
86
+ # ------------------------------------------------------------------
87
+ # status
88
+ # ------------------------------------------------------------------
89
+
90
+ async def status(self, ssh: object, job_id: str) -> str:
91
+ """Check whether the process identified by *job_id* (PID) is alive."""
92
+ proc = self._processes.get(job_id)
93
+
94
+ if proc is not None:
95
+ # We have a handle -- check if it has terminated.
96
+ if proc.returncode is None:
97
+ # Process still running (or hasn't been awaited yet).
98
+ # Use a non-blocking poll.
99
+ try:
100
+ # wait with timeout=0 raises TimeoutError if still running.
101
+ await asyncio.wait_for(asyncio.shield(proc.wait()), timeout=0.0)
102
+ except (asyncio.TimeoutError, asyncio.CancelledError):
103
+ return "running"
104
+
105
+ # Process has finished.
106
+ if proc.returncode == 0:
107
+ return "completed"
108
+ return "failed"
109
+
110
+ # No tracked handle -- try the OS.
111
+ try:
112
+ pid = int(job_id)
113
+ os.kill(pid, 0) # Signal 0: check existence without killing.
114
+ return "running"
115
+ except (OSError, ValueError):
116
+ return "unknown"
117
+
118
+ # ------------------------------------------------------------------
119
+ # cancel
120
+ # ------------------------------------------------------------------
121
+
122
+ async def cancel(self, ssh: object, job_id: str) -> bool:
123
+ """Kill the local process identified by *job_id* (PID)."""
124
+ proc = self._processes.get(job_id)
125
+
126
+ if proc is not None:
127
+ try:
128
+ proc.terminate()
129
+ # Give the process a moment to exit gracefully.
130
+ try:
131
+ await asyncio.wait_for(proc.wait(), timeout=5.0)
132
+ except asyncio.TimeoutError:
133
+ proc.kill()
134
+ logger.info("Cancelled local job pid=%s", job_id)
135
+ return True
136
+ except ProcessLookupError:
137
+ logger.warning("Process pid=%s already exited", job_id)
138
+ return True
139
+
140
+ # Fall back to OS signal.
141
+ try:
142
+ pid = int(job_id)
143
+ os.kill(pid, signal.SIGTERM)
144
+ logger.info("Sent SIGTERM to pid=%s", job_id)
145
+ return True
146
+ except (OSError, ValueError) as exc:
147
+ logger.error("Failed to cancel local job pid=%s: %s", job_id, exc)
148
+ return False
149
+
150
+ # ------------------------------------------------------------------
151
+ # generate_script
152
+ # ------------------------------------------------------------------
153
+
154
+ def generate_script(
155
+ self,
156
+ command: str,
157
+ job_name: str,
158
+ working_dir: str,
159
+ time_limit: str = "8:00:00",
160
+ env: dict[str, str] | None = None,
161
+ ) -> str:
162
+ """Generate a simple bash script for local execution."""
163
+ env_exports = ""
164
+ if env:
165
+ lines = [
166
+ f"export {key}={_shell_quote(value)}" for key, value in env.items()
167
+ ]
168
+ env_exports = "\n".join(lines) + "\n"
169
+
170
+ script = textwrap.dedent(f"""\
171
+ #!/usr/bin/env bash
172
+ set -euo pipefail
173
+
174
+ echo "=== Local job '{job_name}' started at $(date -u) ==="
175
+ echo "Host: $(hostname)"
176
+ echo "Working directory: $(pwd)"
177
+
178
+ {env_exports}# --- Run the command ---
179
+ {command}
180
+
181
+ echo "=== Local job '{job_name}' finished at $(date -u) ==="
182
+ """)
183
+ return script
184
+
185
+
186
+ def _shell_quote(value: str) -> str:
187
+ """Wrap *value* in single quotes, escaping embedded single quotes."""
188
+ return "'" + value.replace("'", "'\\''") + "'"
@@ -0,0 +1,163 @@
1
+ """Sun Grid Engine (SGE) / Open Grid Scheduler implementation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import re
7
+ import textwrap
8
+
9
+ from researchloop.schedulers.base import BaseScheduler
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+ # Maps SGE state codes to normalised status strings.
14
+ _SGE_STATE_MAP: dict[str, str] = {
15
+ "qw": "pending", # queued waiting
16
+ "hqw": "pending", # hold queued waiting
17
+ "r": "running",
18
+ "t": "running", # transferring
19
+ "s": "running", # suspended
20
+ "S": "running", # suspended by queue
21
+ "T": "running", # threshold
22
+ "Eqw": "failed", # error
23
+ "dr": "failed", # deleting/running
24
+ "dt": "failed", # deleting/transferring
25
+ }
26
+
27
+
28
+ class SGEScheduler(BaseScheduler):
29
+ """Job scheduler for SGE/Grid Engine clusters."""
30
+
31
+ async def submit(
32
+ self,
33
+ ssh: object,
34
+ script: str,
35
+ job_name: str,
36
+ working_dir: str,
37
+ env: dict[str, str] | None = None,
38
+ ) -> str:
39
+ # Submit via qsub — script is a remote file path
40
+ submit_cmd = f"cd {working_dir} && qsub {script}"
41
+ stdout, stderr, rc = await ssh.run( # type: ignore[attr-defined]
42
+ submit_cmd, timeout=60
43
+ )
44
+ if rc != 0:
45
+ raise RuntimeError(f"qsub failed (exit {rc}): {stderr}")
46
+
47
+ # Parse job ID from
48
+ # "Your job XXXXX ("name") has been submitted"
49
+ match = re.search(r"Your job\s+(\d+)", stdout)
50
+ if not match:
51
+ raise RuntimeError(f"Could not parse job ID from qsub: {stdout!r}")
52
+
53
+ job_id = match.group(1)
54
+ logger.info(
55
+ "Submitted SGE job %s (name=%s)",
56
+ job_id,
57
+ job_name,
58
+ )
59
+
60
+ return job_id
61
+
62
+ async def status(self, ssh: object, job_id: str) -> str:
63
+ # Try qstat first
64
+ stdout, stderr, rc = await ssh.run( # type: ignore[attr-defined]
65
+ f"qstat -j {job_id}", timeout=30
66
+ )
67
+ if rc == 0 and stdout.strip():
68
+ # Parse state from qstat output
69
+ for line in stdout.splitlines():
70
+ if line.strip().startswith("job_state"):
71
+ # Format: job_state 1: r
72
+ parts = line.split(":")
73
+ if len(parts) >= 2:
74
+ state = parts[-1].strip()
75
+ return _SGE_STATE_MAP.get(state, "unknown")
76
+ # Job exists but couldn't parse state
77
+ return "running"
78
+
79
+ # Try qstat listing format
80
+ stdout, stderr, rc = await ssh.run( # type: ignore[attr-defined]
81
+ f"qstat | grep {job_id}", timeout=30
82
+ )
83
+ if rc == 0 and stdout.strip():
84
+ parts = stdout.split()
85
+ if len(parts) >= 5:
86
+ state = parts[4]
87
+ return _SGE_STATE_MAP.get(state, "unknown")
88
+
89
+ # Job not in queue -- try qacct for finished jobs
90
+ stdout, stderr, rc = await ssh.run( # type: ignore[attr-defined]
91
+ f"qacct -j {job_id}", timeout=30
92
+ )
93
+ if rc == 0 and stdout.strip():
94
+ for line in stdout.splitlines():
95
+ if "exit_status" in line:
96
+ parts = line.split()
97
+ if len(parts) >= 2:
98
+ code = parts[-1].strip()
99
+ if code == "0":
100
+ return "completed"
101
+ return "failed"
102
+ return "completed"
103
+
104
+ logger.warning(
105
+ "Could not determine status for SGE job %s",
106
+ job_id,
107
+ )
108
+ return "unknown"
109
+
110
+ async def cancel(self, ssh: object, job_id: str) -> bool:
111
+ stdout, stderr, rc = await ssh.run( # type: ignore[attr-defined]
112
+ f"qdel {job_id}", timeout=30
113
+ )
114
+ if rc == 0:
115
+ logger.info("Cancelled SGE job %s", job_id)
116
+ return True
117
+ logger.error(
118
+ "qdel failed for job %s (exit %d): %s",
119
+ job_id,
120
+ rc,
121
+ stderr,
122
+ )
123
+ return False
124
+
125
+ def generate_script(
126
+ self,
127
+ command: str,
128
+ job_name: str,
129
+ working_dir: str,
130
+ time_limit: str = "8:00:00",
131
+ env: dict[str, str] | None = None,
132
+ ) -> str:
133
+ env_exports = ""
134
+ if env:
135
+ lines = [f"export {k}={_shell_quote(v)}" for k, v in env.items()]
136
+ env_exports = "\n".join(lines) + "\n"
137
+
138
+ script = textwrap.dedent(f"""\
139
+ #!/usr/bin/env bash
140
+ #$ -N {job_name}
141
+ #$ -o {working_dir}/{job_name}_$JOB_ID.out
142
+ #$ -e {working_dir}/{job_name}_$JOB_ID.err
143
+ #$ -l h_rt={time_limit}
144
+ #$ -cwd
145
+ #$ -S /bin/bash
146
+
147
+ set -euo pipefail
148
+
149
+ echo "=== SGE Job $JOB_ID started ==="
150
+ echo "Host: $(hostname)"
151
+
152
+ {env_exports}# --- Run the command ---
153
+ {command}
154
+
155
+ echo "=== SGE Job $JOB_ID finished ==="
156
+ """)
157
+ return script
158
+
159
+
160
+ def _shell_quote(value: str) -> str:
161
+ """Wrap *value* in single quotes, escaping embedded
162
+ single quotes."""
163
+ return "'" + value.replace("'", "'\\''") + "'"