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,465 @@
|
|
|
1
|
+
"""Slack conversation manager -- maps threads to Claude sessions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import re
|
|
9
|
+
from typing import TYPE_CHECKING, Any
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from researchloop.db.database import Database
|
|
13
|
+
from researchloop.sprints.manager import SprintManager
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
_ACTION_RE = re.compile(r"\[ACTION:\s*(\w+)\s*(\{.*?\})\]", re.DOTALL)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _md_to_slack(text: str) -> str:
|
|
21
|
+
"""Convert markdown formatting to Slack mrkdwn."""
|
|
22
|
+
lines = text.split("\n")
|
|
23
|
+
result = []
|
|
24
|
+
for line in lines:
|
|
25
|
+
line = re.sub(r"^#{1,6}\s+(.+)$", r"*\1*", line)
|
|
26
|
+
line = re.sub(r"\*\*(.+?)\*\*", r"*\1*", line)
|
|
27
|
+
line = re.sub(r"\[([^\]]+)\]\(([^)]+)\)", r"<\2|\1>", line)
|
|
28
|
+
result.append(line)
|
|
29
|
+
return "\n".join(result)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ConversationManager:
|
|
33
|
+
"""Maps Slack threads to Claude CLI sessions.
|
|
34
|
+
|
|
35
|
+
Stores message history locally so we don't need to
|
|
36
|
+
query Slack's API for thread context.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(
|
|
40
|
+
self,
|
|
41
|
+
db: Database,
|
|
42
|
+
sprint_manager: SprintManager | None = None,
|
|
43
|
+
) -> None:
|
|
44
|
+
self.db = db
|
|
45
|
+
self.sprint_manager = sprint_manager
|
|
46
|
+
|
|
47
|
+
# ----------------------------------------------------------
|
|
48
|
+
# Session CRUD
|
|
49
|
+
# ----------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
async def get_session(self, thread_ts: str) -> dict | None:
|
|
52
|
+
return await self.db.fetch_one(
|
|
53
|
+
"SELECT * FROM slack_sessions WHERE thread_ts = ?",
|
|
54
|
+
(thread_ts,),
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
async def create_session(
|
|
58
|
+
self,
|
|
59
|
+
thread_ts: str,
|
|
60
|
+
study_name: str | None = None,
|
|
61
|
+
sprint_id: str | None = None,
|
|
62
|
+
session_id: str | None = None,
|
|
63
|
+
messages: list[dict] | None = None,
|
|
64
|
+
) -> None:
|
|
65
|
+
await self.db.execute(
|
|
66
|
+
"INSERT INTO slack_sessions"
|
|
67
|
+
" (thread_ts, sprint_id, session_id,"
|
|
68
|
+
" study_name, messages_json)"
|
|
69
|
+
" VALUES (?, ?, ?, ?, ?)",
|
|
70
|
+
(
|
|
71
|
+
thread_ts,
|
|
72
|
+
sprint_id,
|
|
73
|
+
session_id,
|
|
74
|
+
study_name,
|
|
75
|
+
json.dumps(messages or []),
|
|
76
|
+
),
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
async def update_session_id(self, thread_ts: str, session_id: str) -> None:
|
|
80
|
+
await self.db.execute(
|
|
81
|
+
"UPDATE slack_sessions SET session_id = ? WHERE thread_ts = ?",
|
|
82
|
+
(session_id, thread_ts),
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
async def _append_message(
|
|
86
|
+
self,
|
|
87
|
+
thread_ts: str,
|
|
88
|
+
role: str,
|
|
89
|
+
text: str,
|
|
90
|
+
) -> None:
|
|
91
|
+
"""Append a message to the thread's stored history."""
|
|
92
|
+
session = await self.get_session(thread_ts)
|
|
93
|
+
if session is None:
|
|
94
|
+
return
|
|
95
|
+
msgs = []
|
|
96
|
+
raw = session.get("messages_json")
|
|
97
|
+
if raw:
|
|
98
|
+
try:
|
|
99
|
+
msgs = json.loads(raw)
|
|
100
|
+
except (json.JSONDecodeError, TypeError):
|
|
101
|
+
pass
|
|
102
|
+
msgs.append({"role": role, "text": text})
|
|
103
|
+
await self.db.execute(
|
|
104
|
+
"UPDATE slack_sessions SET messages_json = ? WHERE thread_ts = ?",
|
|
105
|
+
(json.dumps(msgs), thread_ts),
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
async def store_bot_message(self, thread_ts: str, text: str) -> None:
|
|
109
|
+
"""Store a bot message (e.g. notification) for a thread."""
|
|
110
|
+
session = await self.get_session(thread_ts)
|
|
111
|
+
if session is None:
|
|
112
|
+
# Create a session for this thread.
|
|
113
|
+
# Extract sprint ID from the text if present.
|
|
114
|
+
sid = None
|
|
115
|
+
match = re.search(r"sp-[0-9a-f]{6}", text)
|
|
116
|
+
if match:
|
|
117
|
+
sid = match.group(0)
|
|
118
|
+
await self.create_session(
|
|
119
|
+
thread_ts,
|
|
120
|
+
sprint_id=sid,
|
|
121
|
+
messages=[{"role": "bot", "text": text}],
|
|
122
|
+
)
|
|
123
|
+
else:
|
|
124
|
+
await self._append_message(thread_ts, "bot", text)
|
|
125
|
+
|
|
126
|
+
# ----------------------------------------------------------
|
|
127
|
+
# Context building
|
|
128
|
+
# ----------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
async def _build_context(self) -> str:
|
|
131
|
+
parts = [
|
|
132
|
+
"You are the ResearchLoop assistant, helping "
|
|
133
|
+
"researchers plan and manage automated research "
|
|
134
|
+
"sprints on HPC clusters.",
|
|
135
|
+
"",
|
|
136
|
+
"IMPORTANT: Format responses for Slack. Use *bold* "
|
|
137
|
+
"(single asterisks), _italic_, `code`, and bullet "
|
|
138
|
+
"points with •. Do NOT use markdown headers (##), "
|
|
139
|
+
"**double asterisks**, or [links](url).",
|
|
140
|
+
"",
|
|
141
|
+
"You can:",
|
|
142
|
+
"- Discuss research ideas and help plan sprints",
|
|
143
|
+
"- Review results from completed sprints",
|
|
144
|
+
"- Suggest what to investigate next",
|
|
145
|
+
"- Look up papers and references (web access)",
|
|
146
|
+
"- Execute actions via [ACTION: ...] tags",
|
|
147
|
+
"",
|
|
148
|
+
"## Available Actions",
|
|
149
|
+
'[ACTION: sprint_run {"study": "name", "idea": "..."}]',
|
|
150
|
+
'[ACTION: sprint_list {"study": "name"}]',
|
|
151
|
+
'[ACTION: sprint_show {"id": "sp-abc123"}]',
|
|
152
|
+
'[ACTION: sprint_cancel {"id": "sp-abc123"}]',
|
|
153
|
+
'[ACTION: study_show {"name": "study-name"}]',
|
|
154
|
+
'[ACTION: loop_start {"study": "name", "count": 5, '
|
|
155
|
+
'"context": "optional guidance"}]',
|
|
156
|
+
"",
|
|
157
|
+
"Only include an action when the user clearly wants "
|
|
158
|
+
"to execute it. Always explain what you're doing.",
|
|
159
|
+
"",
|
|
160
|
+
]
|
|
161
|
+
|
|
162
|
+
studies = await self.db.fetch_all(
|
|
163
|
+
"SELECT name, cluster, description FROM studies"
|
|
164
|
+
)
|
|
165
|
+
if studies:
|
|
166
|
+
parts.append("## Available Studies")
|
|
167
|
+
for s in studies:
|
|
168
|
+
desc = s.get("description") or ""
|
|
169
|
+
parts.append(f"- *{s['name']}*: {desc}")
|
|
170
|
+
parts.append("")
|
|
171
|
+
|
|
172
|
+
sprints = await self.db.fetch_all(
|
|
173
|
+
"SELECT id, study_name, idea, status, summary "
|
|
174
|
+
"FROM sprints ORDER BY created_at DESC LIMIT 10"
|
|
175
|
+
)
|
|
176
|
+
if sprints:
|
|
177
|
+
parts.append("## Recent Sprints")
|
|
178
|
+
for sp in sprints:
|
|
179
|
+
idea = (sp.get("idea") or "")[:80]
|
|
180
|
+
summary = (sp.get("summary") or "")[:100]
|
|
181
|
+
parts.append(f"- {sp['id']} [{sp['status']}] {idea}")
|
|
182
|
+
if summary:
|
|
183
|
+
parts.append(f" Summary: {summary}")
|
|
184
|
+
parts.append("")
|
|
185
|
+
|
|
186
|
+
return "\n".join(parts)
|
|
187
|
+
|
|
188
|
+
async def _sprint_context(self, sprint_id: str) -> str:
|
|
189
|
+
sp = await self.db.fetch_one(
|
|
190
|
+
"SELECT * FROM sprints WHERE id = ?",
|
|
191
|
+
(sprint_id,),
|
|
192
|
+
)
|
|
193
|
+
if not sp:
|
|
194
|
+
return ""
|
|
195
|
+
parts = [
|
|
196
|
+
f"## This Thread is About Sprint {sprint_id}",
|
|
197
|
+
f"*Study:* {sp['study_name']}",
|
|
198
|
+
f"*Status:* {sp['status']}",
|
|
199
|
+
f"*Idea:* {sp.get('idea') or 'N/A'}",
|
|
200
|
+
]
|
|
201
|
+
if sp.get("summary"):
|
|
202
|
+
parts.append(f"*Summary:* {sp['summary']}")
|
|
203
|
+
parts.append(
|
|
204
|
+
"When the user says 'same prompt', 'this sprint',"
|
|
205
|
+
" or 'again', they mean this one."
|
|
206
|
+
)
|
|
207
|
+
return "\n".join(parts)
|
|
208
|
+
|
|
209
|
+
# ----------------------------------------------------------
|
|
210
|
+
# Message handling
|
|
211
|
+
# ----------------------------------------------------------
|
|
212
|
+
|
|
213
|
+
async def handle_message(
|
|
214
|
+
self,
|
|
215
|
+
thread_ts: str,
|
|
216
|
+
user_text: str,
|
|
217
|
+
study_name: str | None = None,
|
|
218
|
+
sprint_id: str | None = None,
|
|
219
|
+
channel: str | None = None,
|
|
220
|
+
bot_token: str | None = None,
|
|
221
|
+
) -> str:
|
|
222
|
+
"""Handle a conversational message from Slack."""
|
|
223
|
+
session = await self.get_session(thread_ts)
|
|
224
|
+
resume_id = session["session_id"] if session else None
|
|
225
|
+
|
|
226
|
+
prompt = user_text
|
|
227
|
+
if session is None:
|
|
228
|
+
context = await self._build_context()
|
|
229
|
+
|
|
230
|
+
# Auto-detect sprint ID from text.
|
|
231
|
+
if not sprint_id:
|
|
232
|
+
match = re.search(r"sp-[0-9a-f]{6}", user_text)
|
|
233
|
+
if match:
|
|
234
|
+
sprint_id = match.group(0)
|
|
235
|
+
|
|
236
|
+
if sprint_id:
|
|
237
|
+
extra = await self._sprint_context(sprint_id)
|
|
238
|
+
if extra:
|
|
239
|
+
context += f"\n\n{extra}"
|
|
240
|
+
|
|
241
|
+
prompt = f"{context}\n\nUser: {user_text}"
|
|
242
|
+
else:
|
|
243
|
+
# Existing session — check stored sprint context.
|
|
244
|
+
if not sprint_id:
|
|
245
|
+
sprint_id = session.get("sprint_id")
|
|
246
|
+
|
|
247
|
+
# Include stored thread history in the prompt
|
|
248
|
+
# if this is the first Claude call (no resume_id).
|
|
249
|
+
if not resume_id:
|
|
250
|
+
raw = session.get("messages_json")
|
|
251
|
+
if raw:
|
|
252
|
+
try:
|
|
253
|
+
msgs = json.loads(raw)
|
|
254
|
+
if msgs:
|
|
255
|
+
history = "\n".join(
|
|
256
|
+
f"{m['role'].title()}: {m['text'][:500]}" for m in msgs
|
|
257
|
+
)
|
|
258
|
+
context = await self._build_context()
|
|
259
|
+
if sprint_id:
|
|
260
|
+
extra = await self._sprint_context(sprint_id)
|
|
261
|
+
if extra:
|
|
262
|
+
context += f"\n\n{extra}"
|
|
263
|
+
context += "\n\n## Prior Messages\n" + history
|
|
264
|
+
prompt = f"{context}\n\nUser: {user_text}"
|
|
265
|
+
except (json.JSONDecodeError, TypeError):
|
|
266
|
+
pass
|
|
267
|
+
|
|
268
|
+
# Run Claude with restricted tools — web only.
|
|
269
|
+
cmd = [
|
|
270
|
+
"claude",
|
|
271
|
+
"-p",
|
|
272
|
+
prompt,
|
|
273
|
+
"--output-format",
|
|
274
|
+
"json",
|
|
275
|
+
"--allowedTools",
|
|
276
|
+
"WebFetch",
|
|
277
|
+
"WebSearch",
|
|
278
|
+
]
|
|
279
|
+
if resume_id:
|
|
280
|
+
cmd.extend(["--resume", resume_id])
|
|
281
|
+
|
|
282
|
+
try:
|
|
283
|
+
proc = await asyncio.create_subprocess_exec(
|
|
284
|
+
*cmd,
|
|
285
|
+
stdout=asyncio.subprocess.PIPE,
|
|
286
|
+
stderr=asyncio.subprocess.PIPE,
|
|
287
|
+
)
|
|
288
|
+
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=120)
|
|
289
|
+
except asyncio.TimeoutError:
|
|
290
|
+
return "Sorry, the request timed out."
|
|
291
|
+
except FileNotFoundError:
|
|
292
|
+
return "Claude CLI is not available."
|
|
293
|
+
|
|
294
|
+
if proc.returncode != 0:
|
|
295
|
+
logger.error(
|
|
296
|
+
"Claude CLI failed: %s",
|
|
297
|
+
stderr.decode()[:500],
|
|
298
|
+
)
|
|
299
|
+
return "Sorry, something went wrong."
|
|
300
|
+
|
|
301
|
+
# Parse response.
|
|
302
|
+
raw = stdout.decode("utf-8", errors="replace").strip()
|
|
303
|
+
response_text = raw
|
|
304
|
+
new_session_id = None
|
|
305
|
+
try:
|
|
306
|
+
data = json.loads(raw)
|
|
307
|
+
if isinstance(data, dict):
|
|
308
|
+
response_text = (
|
|
309
|
+
data.get("result", "")
|
|
310
|
+
or data.get("text", "")
|
|
311
|
+
or data.get("content", "")
|
|
312
|
+
or raw
|
|
313
|
+
)
|
|
314
|
+
new_session_id = data.get("session_id")
|
|
315
|
+
except json.JSONDecodeError:
|
|
316
|
+
pass
|
|
317
|
+
|
|
318
|
+
# Persist session.
|
|
319
|
+
if session is None:
|
|
320
|
+
await self.create_session(
|
|
321
|
+
thread_ts,
|
|
322
|
+
study_name=study_name,
|
|
323
|
+
sprint_id=sprint_id,
|
|
324
|
+
session_id=new_session_id,
|
|
325
|
+
messages=[
|
|
326
|
+
{"role": "user", "text": user_text},
|
|
327
|
+
{"role": "bot", "text": response_text[:500]},
|
|
328
|
+
],
|
|
329
|
+
)
|
|
330
|
+
else:
|
|
331
|
+
if new_session_id:
|
|
332
|
+
await self.update_session_id(thread_ts, new_session_id)
|
|
333
|
+
await self._append_message(thread_ts, "user", user_text)
|
|
334
|
+
await self._append_message(thread_ts, "bot", response_text[:500])
|
|
335
|
+
|
|
336
|
+
# Execute any actions Claude requested.
|
|
337
|
+
action_results = await self._execute_actions(response_text)
|
|
338
|
+
if action_results:
|
|
339
|
+
response_text = _ACTION_RE.sub("", response_text).strip()
|
|
340
|
+
response_text += "\n\n" + "\n".join(action_results)
|
|
341
|
+
|
|
342
|
+
return _md_to_slack(response_text)
|
|
343
|
+
|
|
344
|
+
# ----------------------------------------------------------
|
|
345
|
+
# Action execution
|
|
346
|
+
# ----------------------------------------------------------
|
|
347
|
+
|
|
348
|
+
async def _execute_actions(self, text: str) -> list[str]:
|
|
349
|
+
results: list[str] = []
|
|
350
|
+
for match in _ACTION_RE.finditer(text):
|
|
351
|
+
action = match.group(1)
|
|
352
|
+
try:
|
|
353
|
+
params: dict[str, Any] = json.loads(match.group(2))
|
|
354
|
+
except json.JSONDecodeError:
|
|
355
|
+
results.append(f":warning: Failed to parse: {action}")
|
|
356
|
+
continue
|
|
357
|
+
result = await self._run_action(action, params)
|
|
358
|
+
results.append(result)
|
|
359
|
+
return results
|
|
360
|
+
|
|
361
|
+
async def _run_action(self, action: str, params: dict[str, Any]) -> str:
|
|
362
|
+
if self.sprint_manager is None:
|
|
363
|
+
return ":warning: Sprint manager not available."
|
|
364
|
+
|
|
365
|
+
try:
|
|
366
|
+
if action == "sprint_run":
|
|
367
|
+
study = params.get("study", "")
|
|
368
|
+
idea = params.get("idea", "")
|
|
369
|
+
if not study or not idea:
|
|
370
|
+
return ":warning: sprint_run needs 'study' and 'idea'"
|
|
371
|
+
sprint = await self.sprint_manager.run_sprint(study, idea)
|
|
372
|
+
return f":rocket: Sprint *{sprint.id}* submitted for *{study}*"
|
|
373
|
+
|
|
374
|
+
if action == "sprint_list":
|
|
375
|
+
study = params.get("study")
|
|
376
|
+
sprints = await self.sprint_manager.list_sprints(
|
|
377
|
+
study_name=study, limit=10
|
|
378
|
+
)
|
|
379
|
+
if not sprints:
|
|
380
|
+
return "No sprints found."
|
|
381
|
+
lines = [
|
|
382
|
+
f"• *{s['id']}* [{s['status']}] {(s.get('idea') or '')[:50]}"
|
|
383
|
+
for s in sprints
|
|
384
|
+
]
|
|
385
|
+
return "Sprints:\n" + "\n".join(lines)
|
|
386
|
+
|
|
387
|
+
if action == "sprint_show":
|
|
388
|
+
sid = params.get("id", "")
|
|
389
|
+
if not sid:
|
|
390
|
+
return ":warning: sprint_show needs 'id'"
|
|
391
|
+
sp = await self.sprint_manager.get_sprint(sid)
|
|
392
|
+
if not sp:
|
|
393
|
+
return f":warning: Sprint {sid} not found"
|
|
394
|
+
idea = (sp.get("idea") or "")[:100]
|
|
395
|
+
summary = sp.get("summary") or ""
|
|
396
|
+
return (
|
|
397
|
+
f"*{sp['id']}* [{sp['status']}]\n"
|
|
398
|
+
f"*Study:* {sp['study_name']}\n"
|
|
399
|
+
f"*Idea:* {idea}\n"
|
|
400
|
+
f"*Created:* {sp['created_at']}\n"
|
|
401
|
+
+ (f"*Summary:* {summary}" if summary else "")
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
if action == "sprint_cancel":
|
|
405
|
+
sid = params.get("id", "")
|
|
406
|
+
if not sid:
|
|
407
|
+
return ":warning: sprint_cancel needs 'id'"
|
|
408
|
+
ok = await self.sprint_manager.cancel_sprint(sid)
|
|
409
|
+
return (
|
|
410
|
+
f":octagonal_sign: Sprint {sid} cancelled"
|
|
411
|
+
if ok
|
|
412
|
+
else f":warning: Failed to cancel {sid}"
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
if action == "study_show":
|
|
416
|
+
from researchloop.db import queries
|
|
417
|
+
|
|
418
|
+
name = params.get("name", "")
|
|
419
|
+
if not name:
|
|
420
|
+
return ":warning: study_show needs 'name'"
|
|
421
|
+
study = await queries.get_study(self.db, name)
|
|
422
|
+
if not study:
|
|
423
|
+
return f":warning: Study {name} not found"
|
|
424
|
+
sprints = await queries.list_sprints(self.db, study_name=name, limit=5)
|
|
425
|
+
lines = [
|
|
426
|
+
f"*{study['name']}*\n"
|
|
427
|
+
f"*Cluster:* {study['cluster']}\n"
|
|
428
|
+
f"*Description:* "
|
|
429
|
+
f"{study.get('description', '')}\n"
|
|
430
|
+
]
|
|
431
|
+
if sprints:
|
|
432
|
+
lines.append("*Recent sprints:*")
|
|
433
|
+
for s in sprints:
|
|
434
|
+
lines.append(
|
|
435
|
+
f" • {s['id']} [{s['status']}]"
|
|
436
|
+
f" {(s.get('idea') or '')[:40]}"
|
|
437
|
+
)
|
|
438
|
+
return "\n".join(lines)
|
|
439
|
+
|
|
440
|
+
if action == "loop_start":
|
|
441
|
+
from researchloop.sprints.auto_loop import (
|
|
442
|
+
AutoLoopController,
|
|
443
|
+
)
|
|
444
|
+
|
|
445
|
+
study = params.get("study", "")
|
|
446
|
+
count = params.get("count", 5)
|
|
447
|
+
ctx = params.get("context", "")
|
|
448
|
+
if not study:
|
|
449
|
+
return ":warning: loop_start needs 'study'"
|
|
450
|
+
ctrl = AutoLoopController(
|
|
451
|
+
db=self.sprint_manager.db,
|
|
452
|
+
sprint_manager=self.sprint_manager,
|
|
453
|
+
config=self.sprint_manager.config,
|
|
454
|
+
)
|
|
455
|
+
loop_id = await ctrl.start(study, count, context=ctx)
|
|
456
|
+
return (
|
|
457
|
+
f":repeat: Auto-loop *{loop_id}* "
|
|
458
|
+
f"started for *{study}* ({count} sprints)"
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
return f":warning: Unknown action: {action}"
|
|
462
|
+
|
|
463
|
+
except Exception as exc:
|
|
464
|
+
logger.exception("Action %s failed", action)
|
|
465
|
+
return f":x: Action failed: {exc}"
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""ntfy.sh notification backend for researchloop."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from researchloop.comms.base import BaseNotifier
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class NtfyNotifier(BaseNotifier):
|
|
15
|
+
"""Sends push notifications via `ntfy.sh <https://ntfy.sh>`_.
|
|
16
|
+
|
|
17
|
+
Each notification is a simple HTTP POST with headers controlling
|
|
18
|
+
priority, title, and tags.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(self, url: str = "https://ntfy.sh", topic: str = "") -> None:
|
|
22
|
+
self.url = url.rstrip("/")
|
|
23
|
+
self.topic = topic
|
|
24
|
+
|
|
25
|
+
# ------------------------------------------------------------------
|
|
26
|
+
# Internal helper
|
|
27
|
+
# ------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
async def _send(
|
|
30
|
+
self,
|
|
31
|
+
message: str,
|
|
32
|
+
title: str,
|
|
33
|
+
priority: int = 3,
|
|
34
|
+
tags: str = "",
|
|
35
|
+
) -> None:
|
|
36
|
+
"""POST a notification to ntfy."""
|
|
37
|
+
endpoint = f"{self.url}/{self.topic}"
|
|
38
|
+
headers: dict[str, str] = {
|
|
39
|
+
"Title": title,
|
|
40
|
+
"Priority": str(priority),
|
|
41
|
+
}
|
|
42
|
+
if tags:
|
|
43
|
+
headers["Tags"] = tags
|
|
44
|
+
|
|
45
|
+
try:
|
|
46
|
+
async with httpx.AsyncClient() as client:
|
|
47
|
+
response = await client.post(
|
|
48
|
+
endpoint,
|
|
49
|
+
content=message,
|
|
50
|
+
headers=headers,
|
|
51
|
+
timeout=10.0,
|
|
52
|
+
)
|
|
53
|
+
response.raise_for_status()
|
|
54
|
+
logger.debug("ntfy notification sent: %s", title)
|
|
55
|
+
except httpx.HTTPError:
|
|
56
|
+
logger.exception("Failed to send ntfy notification: %s", title)
|
|
57
|
+
raise
|
|
58
|
+
|
|
59
|
+
# ------------------------------------------------------------------
|
|
60
|
+
# BaseNotifier implementation
|
|
61
|
+
# ------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
async def notify_sprint_started(
|
|
64
|
+
self, sprint_id: str, study_name: str, idea: str
|
|
65
|
+
) -> None:
|
|
66
|
+
await self._send(
|
|
67
|
+
message=f"Sprint {sprint_id} started\nIdea: {idea}",
|
|
68
|
+
title=f"ResearchLoop: {study_name}",
|
|
69
|
+
priority=3,
|
|
70
|
+
tags="rocket",
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
async def notify_sprint_completed(
|
|
74
|
+
self,
|
|
75
|
+
sprint_id: str,
|
|
76
|
+
study_name: str,
|
|
77
|
+
summary: str,
|
|
78
|
+
pdf_path: str | None = None,
|
|
79
|
+
) -> None:
|
|
80
|
+
await self._send(
|
|
81
|
+
message=f"Sprint {sprint_id} completed\nSummary: {summary}",
|
|
82
|
+
title=f"ResearchLoop: {study_name}",
|
|
83
|
+
priority=3,
|
|
84
|
+
tags="white_check_mark",
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
async def notify_sprint_failed(
|
|
88
|
+
self, sprint_id: str, study_name: str, error: str
|
|
89
|
+
) -> None:
|
|
90
|
+
await self._send(
|
|
91
|
+
message=f"Sprint {sprint_id} failed\nError: {error}",
|
|
92
|
+
title=f"ResearchLoop: {study_name}",
|
|
93
|
+
priority=4,
|
|
94
|
+
tags="x",
|
|
95
|
+
)
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Notification router -- fans out notifications to all configured backends."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
|
|
7
|
+
from researchloop.comms.base import BaseNotifier
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class NotificationRouter:
|
|
13
|
+
"""Routes notifications to every registered :class:`BaseNotifier`.
|
|
14
|
+
|
|
15
|
+
Errors from individual notifiers are caught and logged so that a
|
|
16
|
+
single broken backend does not prevent the others from firing.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __init__(self) -> None:
|
|
20
|
+
self._notifiers: list[BaseNotifier] = []
|
|
21
|
+
|
|
22
|
+
def add_notifier(self, notifier: BaseNotifier) -> None:
|
|
23
|
+
"""Register a notification backend."""
|
|
24
|
+
self._notifiers.append(notifier)
|
|
25
|
+
logger.info("Registered notifier: %s", type(notifier).__name__)
|
|
26
|
+
|
|
27
|
+
# ------------------------------------------------------------------
|
|
28
|
+
# Fan-out methods
|
|
29
|
+
# ------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
async def notify_sprint_started(
|
|
32
|
+
self, sprint_id: str, study_name: str, idea: str
|
|
33
|
+
) -> None:
|
|
34
|
+
for notifier in self._notifiers:
|
|
35
|
+
try:
|
|
36
|
+
await notifier.notify_sprint_started(sprint_id, study_name, idea)
|
|
37
|
+
except Exception:
|
|
38
|
+
logger.exception(
|
|
39
|
+
"Error in %s.notify_sprint_started",
|
|
40
|
+
type(notifier).__name__,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
async def notify_sprint_completed(
|
|
44
|
+
self,
|
|
45
|
+
sprint_id: str,
|
|
46
|
+
study_name: str,
|
|
47
|
+
summary: str,
|
|
48
|
+
pdf_path: str | None = None,
|
|
49
|
+
) -> None:
|
|
50
|
+
for notifier in self._notifiers:
|
|
51
|
+
try:
|
|
52
|
+
await notifier.notify_sprint_completed(
|
|
53
|
+
sprint_id, study_name, summary, pdf_path=pdf_path
|
|
54
|
+
)
|
|
55
|
+
except Exception:
|
|
56
|
+
logger.exception(
|
|
57
|
+
"Error in %s.notify_sprint_completed",
|
|
58
|
+
type(notifier).__name__,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
async def notify_sprint_failed(
|
|
62
|
+
self, sprint_id: str, study_name: str, error: str
|
|
63
|
+
) -> None:
|
|
64
|
+
for notifier in self._notifiers:
|
|
65
|
+
try:
|
|
66
|
+
await notifier.notify_sprint_failed(sprint_id, study_name, error)
|
|
67
|
+
except Exception:
|
|
68
|
+
logger.exception(
|
|
69
|
+
"Error in %s.notify_sprint_failed",
|
|
70
|
+
type(notifier).__name__,
|
|
71
|
+
)
|