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
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""Slack integration -- Events API webhook handler and notifier."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import hmac
|
|
7
|
+
import logging
|
|
8
|
+
import time
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from researchloop.comms.base import BaseNotifier
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
_SLACK_API = "https://slack.com/api"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SlackNotifier(BaseNotifier):
|
|
21
|
+
"""Sends notifications to Slack channels/threads."""
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
bot_token: str,
|
|
26
|
+
channel_id: str | None = None,
|
|
27
|
+
dashboard_url: str | None = None,
|
|
28
|
+
conversation_manager: Any = None,
|
|
29
|
+
) -> None:
|
|
30
|
+
self.bot_token = bot_token
|
|
31
|
+
self.channel_id = channel_id
|
|
32
|
+
self.dashboard_url = dashboard_url
|
|
33
|
+
self._cm = conversation_manager
|
|
34
|
+
|
|
35
|
+
async def _post_message(
|
|
36
|
+
self,
|
|
37
|
+
text: str,
|
|
38
|
+
channel: str | None = None,
|
|
39
|
+
thread_ts: str | None = None,
|
|
40
|
+
) -> dict[str, Any]:
|
|
41
|
+
ch = channel or self.channel_id
|
|
42
|
+
if not ch:
|
|
43
|
+
logger.warning("No Slack channel configured")
|
|
44
|
+
return {}
|
|
45
|
+
payload: dict[str, Any] = {
|
|
46
|
+
"channel": ch,
|
|
47
|
+
"text": text,
|
|
48
|
+
}
|
|
49
|
+
if thread_ts:
|
|
50
|
+
payload["thread_ts"] = thread_ts
|
|
51
|
+
async with httpx.AsyncClient() as client:
|
|
52
|
+
resp = await client.post(
|
|
53
|
+
f"{_SLACK_API}/chat.postMessage",
|
|
54
|
+
headers={
|
|
55
|
+
"Authorization": f"Bearer {self.bot_token}",
|
|
56
|
+
"Content-Type": "application/json",
|
|
57
|
+
},
|
|
58
|
+
json=payload,
|
|
59
|
+
timeout=10.0,
|
|
60
|
+
)
|
|
61
|
+
data = resp.json()
|
|
62
|
+
if not data.get("ok"):
|
|
63
|
+
logger.error("Slack API error: %s", data.get("error"))
|
|
64
|
+
return data
|
|
65
|
+
|
|
66
|
+
async def _upload_file(
|
|
67
|
+
self,
|
|
68
|
+
filepath: str,
|
|
69
|
+
filename: str,
|
|
70
|
+
channel: str | None = None,
|
|
71
|
+
initial_comment: str = "",
|
|
72
|
+
) -> dict[str, Any]:
|
|
73
|
+
"""Upload a file to a Slack channel."""
|
|
74
|
+
ch = channel or self.channel_id
|
|
75
|
+
if not ch:
|
|
76
|
+
return {}
|
|
77
|
+
try:
|
|
78
|
+
with open(filepath, "rb") as f:
|
|
79
|
+
async with httpx.AsyncClient() as client:
|
|
80
|
+
resp = await client.post(
|
|
81
|
+
f"{_SLACK_API}/files.upload",
|
|
82
|
+
headers={
|
|
83
|
+
"Authorization": (f"Bearer {self.bot_token}"),
|
|
84
|
+
},
|
|
85
|
+
data={
|
|
86
|
+
"channels": ch,
|
|
87
|
+
"filename": filename,
|
|
88
|
+
"initial_comment": initial_comment,
|
|
89
|
+
},
|
|
90
|
+
files={"file": (filename, f)},
|
|
91
|
+
timeout=30.0,
|
|
92
|
+
)
|
|
93
|
+
data = resp.json()
|
|
94
|
+
if not data.get("ok"):
|
|
95
|
+
logger.error(
|
|
96
|
+
"Slack file upload error: %s",
|
|
97
|
+
data.get("error"),
|
|
98
|
+
)
|
|
99
|
+
return data
|
|
100
|
+
except Exception:
|
|
101
|
+
logger.exception("Failed to upload file to Slack")
|
|
102
|
+
return {}
|
|
103
|
+
|
|
104
|
+
def _link(self, sprint_id: str) -> str:
|
|
105
|
+
if self.dashboard_url:
|
|
106
|
+
url = self.dashboard_url.rstrip("/")
|
|
107
|
+
return f"<{url}/dashboard/sprints/{sprint_id}|{sprint_id}>"
|
|
108
|
+
return sprint_id
|
|
109
|
+
|
|
110
|
+
async def notify_sprint_started(
|
|
111
|
+
self,
|
|
112
|
+
sprint_id: str,
|
|
113
|
+
study_name: str,
|
|
114
|
+
idea: str,
|
|
115
|
+
) -> None:
|
|
116
|
+
link = self._link(sprint_id)
|
|
117
|
+
idea_trunc = idea[:300] + "…" if len(idea) > 300 else idea
|
|
118
|
+
msg = (
|
|
119
|
+
f":rocket: Sprint *{link}* started\n"
|
|
120
|
+
f"*Study:* {study_name}\n"
|
|
121
|
+
f"*Idea:* {idea_trunc}"
|
|
122
|
+
)
|
|
123
|
+
resp = await self._post_message(msg)
|
|
124
|
+
ts = resp.get("ts", "")
|
|
125
|
+
if ts and self._cm:
|
|
126
|
+
await self._cm.store_bot_message(ts, msg)
|
|
127
|
+
|
|
128
|
+
async def notify_sprint_completed(
|
|
129
|
+
self,
|
|
130
|
+
sprint_id: str,
|
|
131
|
+
study_name: str,
|
|
132
|
+
summary: str,
|
|
133
|
+
pdf_path: str | None = None,
|
|
134
|
+
) -> None:
|
|
135
|
+
link = self._link(sprint_id)
|
|
136
|
+
summary_trunc = summary[:500] + "…" if len(summary) > 500 else summary
|
|
137
|
+
msg = (
|
|
138
|
+
f":white_check_mark: Sprint *{link}* completed\n"
|
|
139
|
+
f"*Study:* {study_name}\n"
|
|
140
|
+
f"*Summary:* {summary_trunc}"
|
|
141
|
+
)
|
|
142
|
+
resp = await self._post_message(msg)
|
|
143
|
+
# Store the notification for thread context.
|
|
144
|
+
ts = resp.get("ts", "")
|
|
145
|
+
if ts and self._cm:
|
|
146
|
+
await self._cm.store_bot_message(ts, msg)
|
|
147
|
+
if pdf_path:
|
|
148
|
+
await self._upload_file(
|
|
149
|
+
pdf_path,
|
|
150
|
+
f"{sprint_id}-report.pdf",
|
|
151
|
+
initial_comment=f"Report for sprint {sprint_id}",
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
async def notify_sprint_failed(
|
|
155
|
+
self,
|
|
156
|
+
sprint_id: str,
|
|
157
|
+
study_name: str,
|
|
158
|
+
error: str,
|
|
159
|
+
) -> None:
|
|
160
|
+
link = self._link(sprint_id)
|
|
161
|
+
msg = (
|
|
162
|
+
f":x: Sprint *{link}* failed\n*Study:* {study_name}\n*Error:* {error[:500]}"
|
|
163
|
+
)
|
|
164
|
+
resp = await self._post_message(msg)
|
|
165
|
+
ts = resp.get("ts", "")
|
|
166
|
+
if ts and self._cm:
|
|
167
|
+
await self._cm.store_bot_message(ts, msg)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def verify_slack_signature(
|
|
171
|
+
signing_secret: str,
|
|
172
|
+
timestamp: str,
|
|
173
|
+
body: bytes,
|
|
174
|
+
signature: str,
|
|
175
|
+
) -> bool:
|
|
176
|
+
"""Verify a Slack request signature."""
|
|
177
|
+
if abs(time.time() - int(timestamp)) > 60 * 5:
|
|
178
|
+
return False
|
|
179
|
+
basestring = f"v0:{timestamp}:{body.decode('utf-8')}"
|
|
180
|
+
computed = (
|
|
181
|
+
"v0="
|
|
182
|
+
+ hmac.new(
|
|
183
|
+
signing_secret.encode(),
|
|
184
|
+
basestring.encode(),
|
|
185
|
+
hashlib.sha256,
|
|
186
|
+
).hexdigest()
|
|
187
|
+
)
|
|
188
|
+
return hmac.compare_digest(computed, signature)
|
|
File without changes
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Claude CLI authentication helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def check_claude_auth() -> tuple[bool, str]:
|
|
12
|
+
"""Check whether the Claude CLI is authenticated.
|
|
13
|
+
|
|
14
|
+
Uses ``claude auth status`` which returns instant JSON.
|
|
15
|
+
|
|
16
|
+
Returns ``(ok, detail)`` where *ok* is True if authenticated
|
|
17
|
+
and *detail* is a human-readable status string.
|
|
18
|
+
"""
|
|
19
|
+
claude = shutil.which("claude")
|
|
20
|
+
if claude is None:
|
|
21
|
+
return False, "Claude CLI not found in PATH"
|
|
22
|
+
|
|
23
|
+
try:
|
|
24
|
+
result = subprocess.run(
|
|
25
|
+
[claude, "auth", "status"],
|
|
26
|
+
capture_output=True,
|
|
27
|
+
text=True,
|
|
28
|
+
timeout=10,
|
|
29
|
+
)
|
|
30
|
+
if result.returncode == 0:
|
|
31
|
+
try:
|
|
32
|
+
data = json.loads(result.stdout)
|
|
33
|
+
if data.get("loggedIn"):
|
|
34
|
+
email = data.get("email", "")
|
|
35
|
+
sub = data.get("subscriptionType", "")
|
|
36
|
+
detail = f"{email} ({sub})" if email else "OK"
|
|
37
|
+
return True, detail
|
|
38
|
+
return False, "Not logged in"
|
|
39
|
+
except json.JSONDecodeError:
|
|
40
|
+
return True, "Authenticated"
|
|
41
|
+
return False, "Not logged in — run: researchloop login"
|
|
42
|
+
except subprocess.TimeoutExpired:
|
|
43
|
+
return False, "Claude CLI timed out"
|
|
44
|
+
except Exception as exc:
|
|
45
|
+
return False, f"Error checking auth: {exc}"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
async def check_claude_auth_async() -> tuple[bool, str]:
|
|
49
|
+
"""Async version of :func:`check_claude_auth`."""
|
|
50
|
+
claude = shutil.which("claude")
|
|
51
|
+
if claude is None:
|
|
52
|
+
return False, "Claude CLI not found in PATH"
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
proc = await asyncio.create_subprocess_exec(
|
|
56
|
+
claude,
|
|
57
|
+
"auth",
|
|
58
|
+
"status",
|
|
59
|
+
stdout=asyncio.subprocess.PIPE,
|
|
60
|
+
stderr=asyncio.subprocess.PIPE,
|
|
61
|
+
)
|
|
62
|
+
stdout, _stderr = await asyncio.wait_for(proc.communicate(), timeout=10)
|
|
63
|
+
if proc.returncode == 0:
|
|
64
|
+
try:
|
|
65
|
+
data = json.loads(stdout.decode("utf-8"))
|
|
66
|
+
if data.get("loggedIn"):
|
|
67
|
+
email = data.get("email", "")
|
|
68
|
+
sub = data.get("subscriptionType", "")
|
|
69
|
+
detail = f"{email} ({sub})" if email else "OK"
|
|
70
|
+
return True, detail
|
|
71
|
+
return False, "Not logged in"
|
|
72
|
+
except json.JSONDecodeError:
|
|
73
|
+
return True, "Authenticated"
|
|
74
|
+
return False, "Not logged in"
|
|
75
|
+
except asyncio.TimeoutError:
|
|
76
|
+
return False, "Claude CLI timed out"
|
|
77
|
+
except Exception as exc:
|
|
78
|
+
return False, f"Error checking auth: {exc}"
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
"""Configuration loading for researchloop."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
if sys.version_info >= (3, 11):
|
|
11
|
+
import tomllib
|
|
12
|
+
else:
|
|
13
|
+
try:
|
|
14
|
+
import tomli as tomllib # type: ignore[import-not-found]
|
|
15
|
+
except ImportError as exc:
|
|
16
|
+
raise ImportError(
|
|
17
|
+
"tomli is required for Python < 3.11: pip install tomli"
|
|
18
|
+
) from exc
|
|
19
|
+
|
|
20
|
+
CONFIG_FILENAMES = ["researchloop.toml"]
|
|
21
|
+
CONFIG_SEARCH_PATHS = [
|
|
22
|
+
Path.cwd(),
|
|
23
|
+
Path.home() / ".config" / "researchloop",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class ClusterConfig:
|
|
29
|
+
"""Configuration for a compute cluster."""
|
|
30
|
+
|
|
31
|
+
name: str
|
|
32
|
+
host: str
|
|
33
|
+
port: int = 22
|
|
34
|
+
user: str = ""
|
|
35
|
+
key_path: str = ""
|
|
36
|
+
scheduler_type: str = "slurm" # "slurm", "sge", "local"
|
|
37
|
+
working_dir: str = ""
|
|
38
|
+
max_concurrent_jobs: int = 4
|
|
39
|
+
environment: dict[str, str] = field(default_factory=dict)
|
|
40
|
+
job_options: dict[str, str] = field(default_factory=dict)
|
|
41
|
+
claude_command: str = "claude --dangerously-skip-permissions"
|
|
42
|
+
context: str = ""
|
|
43
|
+
context_paths: list[str] = field(default_factory=list)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class StudyConfig:
|
|
48
|
+
"""Configuration for a research study."""
|
|
49
|
+
|
|
50
|
+
name: str
|
|
51
|
+
cluster: str
|
|
52
|
+
claude_md_path: str = ""
|
|
53
|
+
context: str = ""
|
|
54
|
+
sprints_dir: str = ""
|
|
55
|
+
claude_command: str = ""
|
|
56
|
+
job_options: dict[str, str] = field(default_factory=dict)
|
|
57
|
+
max_sprint_duration_hours: int = 8
|
|
58
|
+
red_team_max_rounds: int = 3
|
|
59
|
+
description: str = ""
|
|
60
|
+
allow_loop: bool = True
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass
|
|
64
|
+
class SlackConfig:
|
|
65
|
+
"""Slack notification settings."""
|
|
66
|
+
|
|
67
|
+
bot_token: str = ""
|
|
68
|
+
signing_secret: str = ""
|
|
69
|
+
channel_id: str | None = None
|
|
70
|
+
allowed_user_ids: list[str] = field(default_factory=list)
|
|
71
|
+
restrict_to_channel: bool = False
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass
|
|
75
|
+
class NtfyConfig:
|
|
76
|
+
"""Ntfy notification settings."""
|
|
77
|
+
|
|
78
|
+
url: str = "https://ntfy.sh"
|
|
79
|
+
topic: str = ""
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass
|
|
83
|
+
class DashboardConfig:
|
|
84
|
+
"""Dashboard web UI settings."""
|
|
85
|
+
|
|
86
|
+
enabled: bool = True
|
|
87
|
+
host: str = "0.0.0.0"
|
|
88
|
+
port: int = 8080
|
|
89
|
+
password_hash: str | None = None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass
|
|
93
|
+
class Config:
|
|
94
|
+
"""Top-level researchloop configuration."""
|
|
95
|
+
|
|
96
|
+
studies: list[StudyConfig] = field(default_factory=list)
|
|
97
|
+
clusters: list[ClusterConfig] = field(default_factory=list)
|
|
98
|
+
slack: SlackConfig | None = None
|
|
99
|
+
ntfy: NtfyConfig | None = None
|
|
100
|
+
dashboard: DashboardConfig = field(default_factory=DashboardConfig)
|
|
101
|
+
claude_command: str = ""
|
|
102
|
+
context: str = ""
|
|
103
|
+
context_paths: list[str] = field(default_factory=list)
|
|
104
|
+
db_path: str = "researchloop.db"
|
|
105
|
+
artifact_dir: str = "artifacts"
|
|
106
|
+
shared_secret: str | None = None
|
|
107
|
+
orchestrator_url: str | None = None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _parse_cluster(data: dict) -> ClusterConfig:
|
|
111
|
+
ctx = data.get("context_paths", [])
|
|
112
|
+
if isinstance(ctx, str):
|
|
113
|
+
ctx = [ctx]
|
|
114
|
+
return ClusterConfig(
|
|
115
|
+
name=data["name"],
|
|
116
|
+
host=data.get("host", ""),
|
|
117
|
+
port=data.get("port", 22),
|
|
118
|
+
user=data.get("user", ""),
|
|
119
|
+
key_path=data.get("key_path", ""),
|
|
120
|
+
scheduler_type=data.get("scheduler_type", "slurm"),
|
|
121
|
+
working_dir=data.get("working_dir", ""),
|
|
122
|
+
max_concurrent_jobs=data.get("max_concurrent_jobs", 4),
|
|
123
|
+
environment=data.get("environment", {}),
|
|
124
|
+
job_options=data.get("job_options", {}),
|
|
125
|
+
claude_command=data.get(
|
|
126
|
+
"claude_command",
|
|
127
|
+
"claude --dangerously-skip-permissions",
|
|
128
|
+
),
|
|
129
|
+
context=data.get("context", ""),
|
|
130
|
+
context_paths=ctx,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _parse_study(data: dict) -> StudyConfig:
|
|
135
|
+
return StudyConfig(
|
|
136
|
+
name=data["name"],
|
|
137
|
+
cluster=data.get("cluster", ""),
|
|
138
|
+
claude_md_path=data.get("claude_md_path", ""),
|
|
139
|
+
context=data.get("context", ""),
|
|
140
|
+
sprints_dir=data.get("sprints_dir", ""),
|
|
141
|
+
max_sprint_duration_hours=data.get("max_sprint_duration_hours", 8),
|
|
142
|
+
red_team_max_rounds=data.get("red_team_max_rounds", 3),
|
|
143
|
+
claude_command=data.get("claude_command", ""),
|
|
144
|
+
job_options=data.get("job_options", {}),
|
|
145
|
+
description=data.get("description", ""),
|
|
146
|
+
allow_loop=data.get("allow_loop", True),
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _parse_config(data: dict) -> Config:
|
|
151
|
+
clusters = [_parse_cluster(c) for c in data.get("cluster", [])]
|
|
152
|
+
studies = [_parse_study(s) for s in data.get("study", [])]
|
|
153
|
+
|
|
154
|
+
slack = None
|
|
155
|
+
if "slack" in data:
|
|
156
|
+
s = data["slack"]
|
|
157
|
+
allowed = s.get("allowed_user_ids", [])
|
|
158
|
+
if isinstance(allowed, str):
|
|
159
|
+
allowed = [allowed]
|
|
160
|
+
slack = SlackConfig(
|
|
161
|
+
bot_token=s.get("bot_token", ""),
|
|
162
|
+
signing_secret=s.get("signing_secret", ""),
|
|
163
|
+
channel_id=s.get("channel_id"),
|
|
164
|
+
allowed_user_ids=allowed,
|
|
165
|
+
restrict_to_channel=s.get("restrict_to_channel", False),
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
ntfy = None
|
|
169
|
+
if "ntfy" in data:
|
|
170
|
+
n = data["ntfy"]
|
|
171
|
+
ntfy = NtfyConfig(
|
|
172
|
+
url=n.get("url", "https://ntfy.sh"),
|
|
173
|
+
topic=n.get("topic", ""),
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
dashboard_data = data.get("dashboard", {})
|
|
177
|
+
dashboard = DashboardConfig(
|
|
178
|
+
enabled=dashboard_data.get("enabled", True),
|
|
179
|
+
host=dashboard_data.get("host", "0.0.0.0"),
|
|
180
|
+
port=dashboard_data.get("port", 8080),
|
|
181
|
+
password_hash=dashboard_data.get("password_hash"),
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
global_ctx = data.get("context_paths", [])
|
|
185
|
+
if isinstance(global_ctx, str):
|
|
186
|
+
global_ctx = [global_ctx]
|
|
187
|
+
|
|
188
|
+
return Config(
|
|
189
|
+
studies=studies,
|
|
190
|
+
clusters=clusters,
|
|
191
|
+
slack=slack,
|
|
192
|
+
ntfy=ntfy,
|
|
193
|
+
dashboard=dashboard,
|
|
194
|
+
claude_command=data.get("claude_command", ""),
|
|
195
|
+
context=data.get("context", ""),
|
|
196
|
+
context_paths=global_ctx,
|
|
197
|
+
db_path=data.get("db_path", "researchloop.db"),
|
|
198
|
+
artifact_dir=data.get("artifact_dir", "artifacts"),
|
|
199
|
+
shared_secret=data.get("shared_secret"),
|
|
200
|
+
orchestrator_url=data.get("orchestrator_url"),
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def load_config(path: str | None = None) -> Config:
|
|
205
|
+
"""Load configuration from a researchloop.toml file.
|
|
206
|
+
|
|
207
|
+
Search order:
|
|
208
|
+
1. Explicit ``path`` argument.
|
|
209
|
+
2. ``researchloop.toml`` in the current working directory.
|
|
210
|
+
3. ``~/.config/researchloop/researchloop.toml``.
|
|
211
|
+
|
|
212
|
+
Returns a ``Config`` instance. Raises ``FileNotFoundError`` if no
|
|
213
|
+
configuration file can be located.
|
|
214
|
+
"""
|
|
215
|
+
if path is not None:
|
|
216
|
+
config_path = Path(path)
|
|
217
|
+
if not config_path.exists():
|
|
218
|
+
raise FileNotFoundError(f"Config file not found: {path}")
|
|
219
|
+
else:
|
|
220
|
+
config_path = None
|
|
221
|
+
for search_dir in CONFIG_SEARCH_PATHS:
|
|
222
|
+
for filename in CONFIG_FILENAMES:
|
|
223
|
+
candidate = search_dir / filename
|
|
224
|
+
if candidate.exists():
|
|
225
|
+
config_path = candidate
|
|
226
|
+
break
|
|
227
|
+
if config_path is not None:
|
|
228
|
+
break
|
|
229
|
+
|
|
230
|
+
if config_path is None:
|
|
231
|
+
raise FileNotFoundError(
|
|
232
|
+
"No researchloop.toml found. Searched: "
|
|
233
|
+
+ ", ".join(str(p) for p in CONFIG_SEARCH_PATHS)
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
with open(config_path, "rb") as f:
|
|
237
|
+
data = tomllib.load(f)
|
|
238
|
+
|
|
239
|
+
config = _parse_config(data)
|
|
240
|
+
_apply_env_overrides(config)
|
|
241
|
+
return config
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
# ------------------------------------------------------------------
|
|
245
|
+
# Environment variable overrides
|
|
246
|
+
# ------------------------------------------------------------------
|
|
247
|
+
|
|
248
|
+
_ENV_PREFIX = "RESEARCHLOOP_"
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _env(name: str) -> str | None:
|
|
252
|
+
"""Read an env var with the RESEARCHLOOP_ prefix."""
|
|
253
|
+
return os.environ.get(f"{_ENV_PREFIX}{name}")
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _apply_env_overrides(config: Config) -> None:
|
|
257
|
+
"""Override config values from environment variables.
|
|
258
|
+
|
|
259
|
+
Env vars take precedence over TOML values. Supported vars::
|
|
260
|
+
|
|
261
|
+
RESEARCHLOOP_SHARED_SECRET
|
|
262
|
+
RESEARCHLOOP_ORCHESTRATOR_URL
|
|
263
|
+
RESEARCHLOOP_DB_PATH
|
|
264
|
+
RESEARCHLOOP_ARTIFACT_DIR
|
|
265
|
+
RESEARCHLOOP_SLACK_BOT_TOKEN
|
|
266
|
+
RESEARCHLOOP_SLACK_SIGNING_SECRET
|
|
267
|
+
RESEARCHLOOP_SLACK_CHANNEL_ID
|
|
268
|
+
RESEARCHLOOP_SLACK_ALLOWED_USER_IDS
|
|
269
|
+
RESEARCHLOOP_NTFY_URL
|
|
270
|
+
RESEARCHLOOP_NTFY_TOPIC
|
|
271
|
+
RESEARCHLOOP_DASHBOARD_PASSWORD
|
|
272
|
+
RESEARCHLOOP_DASHBOARD_PASSWORD_HASH
|
|
273
|
+
RESEARCHLOOP_DASHBOARD_PORT
|
|
274
|
+
RESEARCHLOOP_DASHBOARD_HOST
|
|
275
|
+
"""
|
|
276
|
+
# Top-level secrets / settings
|
|
277
|
+
if v := _env("SHARED_SECRET"):
|
|
278
|
+
config.shared_secret = v
|
|
279
|
+
if v := _env("ORCHESTRATOR_URL"):
|
|
280
|
+
config.orchestrator_url = v
|
|
281
|
+
if v := _env("DB_PATH"):
|
|
282
|
+
config.db_path = v
|
|
283
|
+
if v := _env("ARTIFACT_DIR"):
|
|
284
|
+
config.artifact_dir = v
|
|
285
|
+
|
|
286
|
+
# Slack
|
|
287
|
+
if _env("SLACK_BOT_TOKEN"):
|
|
288
|
+
if config.slack is None:
|
|
289
|
+
config.slack = SlackConfig()
|
|
290
|
+
config.slack.bot_token = _env("SLACK_BOT_TOKEN") or ""
|
|
291
|
+
if _env("SLACK_SIGNING_SECRET"):
|
|
292
|
+
if config.slack is None:
|
|
293
|
+
config.slack = SlackConfig()
|
|
294
|
+
config.slack.signing_secret = _env("SLACK_SIGNING_SECRET") or ""
|
|
295
|
+
if v := _env("SLACK_CHANNEL_ID"):
|
|
296
|
+
if config.slack is None:
|
|
297
|
+
config.slack = SlackConfig()
|
|
298
|
+
config.slack.channel_id = v
|
|
299
|
+
if v := _env("SLACK_ALLOWED_USER_IDS"):
|
|
300
|
+
if config.slack is None:
|
|
301
|
+
config.slack = SlackConfig()
|
|
302
|
+
config.slack.allowed_user_ids = [
|
|
303
|
+
uid.strip() for uid in v.split(",") if uid.strip()
|
|
304
|
+
]
|
|
305
|
+
|
|
306
|
+
# ntfy
|
|
307
|
+
if _env("NTFY_TOPIC"):
|
|
308
|
+
if config.ntfy is None:
|
|
309
|
+
config.ntfy = NtfyConfig()
|
|
310
|
+
config.ntfy.topic = _env("NTFY_TOPIC") or ""
|
|
311
|
+
if v := _env("NTFY_URL"):
|
|
312
|
+
if config.ntfy is None:
|
|
313
|
+
config.ntfy = NtfyConfig()
|
|
314
|
+
config.ntfy.url = v
|
|
315
|
+
|
|
316
|
+
# Dashboard
|
|
317
|
+
if v := _env("DASHBOARD_PASSWORD"):
|
|
318
|
+
import bcrypt
|
|
319
|
+
|
|
320
|
+
config.dashboard.password_hash = bcrypt.hashpw(
|
|
321
|
+
v.encode("utf-8"), bcrypt.gensalt()
|
|
322
|
+
).decode("utf-8")
|
|
323
|
+
if v := _env("DASHBOARD_PASSWORD_HASH"):
|
|
324
|
+
config.dashboard.password_hash = v
|
|
325
|
+
if v := _env("DASHBOARD_PORT"):
|
|
326
|
+
config.dashboard.port = int(v)
|
|
327
|
+
if v := _env("DASHBOARD_HOST"):
|
|
328
|
+
config.dashboard.host = v
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Local CLI credentials for connecting to a remote orchestrator."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
_CREDENTIALS_PATH = Path.home() / ".config" / "researchloop" / "credentials.json"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def save_credentials(url: str, token: str) -> Path:
|
|
12
|
+
"""Save orchestrator URL and API token to disk."""
|
|
13
|
+
_CREDENTIALS_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
14
|
+
_CREDENTIALS_PATH.write_text(
|
|
15
|
+
json.dumps({"url": url, "token": token}, indent=2) + "\n",
|
|
16
|
+
encoding="utf-8",
|
|
17
|
+
)
|
|
18
|
+
_CREDENTIALS_PATH.chmod(0o600)
|
|
19
|
+
return _CREDENTIALS_PATH
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def load_credentials() -> dict[str, str] | None:
|
|
23
|
+
"""Load saved credentials, or None if not configured."""
|
|
24
|
+
if not _CREDENTIALS_PATH.exists():
|
|
25
|
+
return None
|
|
26
|
+
try:
|
|
27
|
+
data = json.loads(_CREDENTIALS_PATH.read_text(encoding="utf-8"))
|
|
28
|
+
if data.get("url") and data.get("token"):
|
|
29
|
+
return data
|
|
30
|
+
return None
|
|
31
|
+
except (json.JSONDecodeError, OSError):
|
|
32
|
+
return None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def clear_credentials() -> None:
|
|
36
|
+
"""Remove saved credentials."""
|
|
37
|
+
if _CREDENTIALS_PATH.exists():
|
|
38
|
+
_CREDENTIALS_PATH.unlink()
|