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,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
+ )