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,84 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>{% block title %}ResearchLoop{% endblock %}</title>
7
+ <style>
8
+ * { margin: 0; padding: 0; box-sizing: border-box; }
9
+ body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #0f172a; color: #e2e8f0; line-height: 1.6; }
10
+ a { color: #60a5fa; text-decoration: none; }
11
+ a:hover { text-decoration: underline; }
12
+ .container { max-width: 1000px; margin: 0 auto; padding: 1.5rem; }
13
+ header { background: #1e293b; border-bottom: 1px solid #334155; padding: 1rem 0; margin-bottom: 2rem; }
14
+ header .container { display: flex; align-items: center; justify-content: space-between; }
15
+ header h1 { font-size: 1.25rem; color: #f8fafc; }
16
+ header nav a { margin-left: 1.5rem; color: #94a3b8; font-size: 0.9rem; }
17
+ header nav a:hover { color: #f8fafc; }
18
+ h2 { font-size: 1.4rem; margin-bottom: 1rem; color: #f8fafc; }
19
+ h3 { font-size: 1.1rem; margin-bottom: 0.5rem; color: #f8fafc; }
20
+ table { width: 100%; border-collapse: collapse; margin-bottom: 1.5rem; }
21
+ th, td { padding: 0.6rem 0.8rem; text-align: left; border-bottom: 1px solid #1e293b; }
22
+ th { color: #94a3b8; font-weight: 600; font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.05em; }
23
+ tr:hover { background: #1e293b; }
24
+ .badge { display: inline-block; padding: 0.15rem 0.5rem; border-radius: 9999px; font-size: 0.75rem; font-weight: 600; white-space: nowrap; }
25
+ .badge-pending { background: #854d0e; color: #fef08a; }
26
+ .badge-running, .badge-research, .badge-red_team, .badge-fixing, .badge-validating, .badge-reporting, .badge-summarizing { background: #1e3a5f; color: #60a5fa; }
27
+ .badge-completed { background: #14532d; color: #86efac; }
28
+ .badge-failed, .badge-cancelled { background: #7f1d1d; color: #fca5a5; }
29
+ .badge-submitted, .badge-uploading { background: #713f12; color: #fde68a; }
30
+ .card { background: #1e293b; border-radius: 0.5rem; padding: 1.25rem; margin-bottom: 1rem; }
31
+ .card dt { color: #94a3b8; font-size: 0.85rem; margin-bottom: 0.2rem; }
32
+ .card dd { margin-bottom: 0.75rem; }
33
+ .dim { color: #64748b; }
34
+ .login-box { max-width: 360px; margin: 4rem auto; }
35
+ .login-box input { width: 100%; padding: 0.6rem; margin-bottom: 0.75rem; background: #0f172a; border: 1px solid #334155; border-radius: 0.375rem; color: #e2e8f0; font-size: 0.95rem; }
36
+ .login-box button, .btn { padding: 0.5rem 1rem; background: #2563eb; color: #fff; border: none; border-radius: 0.375rem; cursor: pointer; font-size: 0.9rem; display: inline-block; }
37
+ .login-box button:hover, .btn:hover { background: #1d4ed8; }
38
+ .btn-danger { background: #dc2626; }
39
+ .btn-danger:hover { background: #b91c1c; }
40
+ .btn-sm { padding: 0.3rem 0.6rem; font-size: 0.8rem; line-height: 1.4; box-sizing: border-box; }
41
+ .actions { margin-top: 1rem; display: flex; gap: 0.5rem; }
42
+ textarea { width: 100%; padding: 0.6rem; background: #0f172a; border: 1px solid #334155; border-radius: 0.375rem; color: #e2e8f0; font-size: 0.95rem; font-family: inherit; resize: vertical; }
43
+ select { padding: 0.6rem; background: #0f172a; border: 1px solid #334155; border-radius: 0.375rem; color: #e2e8f0; font-size: 0.95rem; }
44
+ .form-group { margin-bottom: 0.75rem; }
45
+ .form-group label { display: block; color: #94a3b8; font-size: 0.85rem; margin-bottom: 0.3rem; }
46
+ .error { color: #f87171; margin-bottom: 0.75rem; }
47
+ .success { color: #86efac; margin-bottom: 0.75rem; }
48
+ .report-content h1, .report-content h2, .report-content h3 { color: #f8fafc; margin-top: 1.5rem; margin-bottom: 0.5rem; }
49
+ .report-content h1 { font-size: 1.3rem; }
50
+ .report-content h2 { font-size: 1.15rem; }
51
+ .report-content h3 { font-size: 1rem; }
52
+ .report-content p { margin-bottom: 0.75rem; line-height: 1.7; }
53
+ .report-content ul, .report-content ol { margin-bottom: 0.75rem; padding-left: 1.5rem; }
54
+ .report-content li { margin-bottom: 0.3rem; }
55
+ .report-content code { background: #0f172a; padding: 0.15rem 0.4rem; border-radius: 0.25rem; font-size: 0.85rem; }
56
+ .report-content pre code { display: block; padding: 1rem; overflow-x: auto; }
57
+ .report-content table { border-collapse: collapse; margin-bottom: 1rem; }
58
+ .report-content th, .report-content td { padding: 0.4rem 0.8rem; border: 1px solid #334155; }
59
+ .report-content blockquote { border-left: 3px solid #334155; padding-left: 1rem; color: #94a3b8; margin: 0.75rem 0; }
60
+ pre { background: #0f172a; padding: 1rem; border-radius: 0.375rem; overflow-x: auto; font-size: 0.85rem; }
61
+ .refresh-btn { background: none; border: none; color: #60a5fa; cursor: pointer; font-size: 0.85rem; padding: 0.1rem 0.3rem; border-radius: 0.25rem; }
62
+ .refresh-btn:hover { background: #1e3a5f; }
63
+ @keyframes spin { to { transform: rotate(360deg); } }
64
+ .spinning { animation: spin 0.8s linear infinite; display: inline-block; }
65
+ </style>
66
+ {% block head %}{% endblock %}
67
+ </head>
68
+ <body>
69
+ <header>
70
+ <div class="container">
71
+ <h1><a href="/dashboard/" style="color: #f8fafc; text-decoration: none;">ResearchLoop</a></h1>
72
+ <nav>
73
+ <a href="/dashboard/">Studies</a>
74
+ <a href="/dashboard/sprints">Sprints</a>
75
+ <a href="/dashboard/loops">Loops</a>
76
+ {% if authenticated %}<a href="/dashboard/logout">Logout</a>{% endif %}
77
+ </nav>
78
+ </div>
79
+ </header>
80
+ <div class="container">
81
+ {% block content %}{% endblock %}
82
+ </div>
83
+ </body>
84
+ </html>
@@ -0,0 +1,12 @@
1
+ {% extends "base.html" %}
2
+ {% block title %}Login — ResearchLoop{% endblock %}
3
+ {% block content %}
4
+ <div class="login-box">
5
+ <h2>Login</h2>
6
+ {% if error %}<p class="error">{{ error }}</p>{% endif %}
7
+ <form method="post" action="/dashboard/login">
8
+ <input type="password" name="password" placeholder="Password" autofocus>
9
+ <button type="submit">Sign in</button>
10
+ </form>
11
+ </div>
12
+ {% endblock %}
@@ -0,0 +1,58 @@
1
+ {% extends "base.html" %}
2
+ {% block title %}{{ loop.id }} — ResearchLoop{% endblock %}
3
+ {% block head %}
4
+ {% if loop.status in ('running', 'pending') %}
5
+ <script>setTimeout(function(){window.location.reload()},30000);</script>
6
+ {% endif %}
7
+ {% endblock %}
8
+ {% block content %}
9
+ <h2>Loop {{ loop.id }}</h2>
10
+ <div class="card">
11
+ <dl>
12
+ <dt>Study</dt><dd><a href="/dashboard/studies/{{ loop.study_name }}">{{ loop.study_name }}</a></dd>
13
+ <dt>Status</dt><dd><span class="badge badge-{{ loop.status }}">{{ loop.status }}</span></dd>
14
+ <dt>Progress</dt><dd>{{ loop.completed_count }}/{{ loop.total_count }}</dd>
15
+ <dt>Current Sprint</dt><dd>{% if loop.current_sprint_id %}<a href="/dashboard/sprints/{{ loop.current_sprint_id }}">{{ loop.current_sprint_id }}</a>{% else %}—{% endif %}</dd>
16
+ <dt>Created</dt><dd>{{ loop.created_at }}</dd>
17
+ {% if loop.stopped_at %}<dt>Stopped</dt><dd>{{ loop.stopped_at }}</dd>{% endif %}
18
+ </dl>
19
+ <div class="actions" style="display: flex; gap: 0.5rem;">
20
+ {% if loop.status in ('running', 'pending') %}
21
+ <form method="post" action="/dashboard/loops/{{ loop.id }}/stop">
22
+ <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
23
+ <button type="submit" class="btn btn-danger btn-sm" onclick="return confirm('Stop this loop?')">Stop loop</button>
24
+ </form>
25
+ {% endif %}
26
+ {% if loop.status in ('stopped', 'failed') and loop.completed_count < loop.total_count %}
27
+ <form method="post" action="/dashboard/loops/{{ loop.id }}/resume" onsubmit="var b=this.querySelector('button');if(b.disabled)return false;b.disabled=true;b.textContent='Resuming...';">
28
+ <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
29
+ <button type="submit" class="btn btn-sm">Resume loop</button>
30
+ </form>
31
+ {% endif %}
32
+ </div>
33
+ </div>
34
+ {% if context %}
35
+ <h3>Idea Generation Context</h3>
36
+ <div class="card report-content">{{ context|markdown|safe }}</div>
37
+ {% endif %}
38
+ <h3>Sprints</h3>
39
+ <table>
40
+ <thead><tr><th>ID</th><th>Status</th><th>Idea</th><th>Summary</th><th>Created</th><th></th></tr></thead>
41
+ <tbody>
42
+ {% for sp in sprints %}
43
+ <tr>
44
+ <td><a href="/dashboard/sprints/{{ sp.id }}">{{ sp.id }}</a></td>
45
+ <td><span class="badge badge-{{ sp.status.split(' ')[0] }}">{{ sp.status.split(' ')[0] }}</span>{% if '(' in sp.status %} <span class="dim" style="font-size:0.75rem">{{ sp.status.split('(')[1].rstrip(')') }}</span>{% endif %}</td>
46
+ <td>{% if sp.idea %}{{ sp.idea[:60] }}{% if sp.idea|length > 60 %}…{% endif %}{% elif sp.status not in ('completed', 'failed', 'cancelled') %}<span class="dim" style="font-style:italic">auto-generating...</span>{% elif sp.summary %}<span class="dim">{{ sp.summary[:60] }}{% if sp.summary|length > 60 %}…{% endif %}</span>{% endif %}</td>
47
+ <td class="dim">{{ (sp.summary or '')[:80] }}{% if (sp.summary or '')|length > 80 %}…{% endif %}</td>
48
+ <td class="dim">{{ sp.created_at }}</td>
49
+ <td>
50
+ {% if sp.status.startswith('running') or sp.status == 'submitted' %}
51
+ <button type="button" class="refresh-btn" title="Refresh status" onclick="event.stopPropagation();var b=this;b.innerHTML='<span class=spinning>&#8635;</span>';fetch('/dashboard/sprints/{{ sp.id }}/refresh',{method:'POST',headers:{'X-CSRF-Token':'{{ csrf_token }}'},redirect:'manual'}).then(function(){b.innerHTML='&#10003;';b.style.color='#86efac';setTimeout(function(){b.innerHTML='&#8635;';b.style.color='';},2000)})">&#8635;</button>
52
+ {% endif %}
53
+ </td>
54
+ </tr>
55
+ {% endfor %}
56
+ </tbody>
57
+ </table>
58
+ {% endblock %}
@@ -0,0 +1,61 @@
1
+ {% extends "base.html" %}
2
+ {% block title %}Auto-Loops — ResearchLoop{% endblock %}
3
+ {% block content %}
4
+ <h2>Auto-Loops</h2>
5
+
6
+ <h3>New loop</h3>
7
+ <div class="card">
8
+ <form method="post" action="/dashboard/loops/new" onsubmit="var b=this.querySelector('button[type=submit]');if(b.disabled)return false;b.disabled=true;b.textContent='Starting...';">
9
+ <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
10
+ <div class="form-group">
11
+ <label for="study_name">Study</label>
12
+ <select name="study_name" id="study_name" required>
13
+ {% for s in studies %}<option value="{{ s }}">{{ s }}</option>{% endfor %}
14
+ </select>
15
+ </div>
16
+ <div class="form-group">
17
+ <label for="count">Number of sprints</label>
18
+ <input type="number" name="count" id="count" value="5" min="1" max="50" required style="width:80px">
19
+ </div>
20
+ <div class="form-group">
21
+ <label for="context">Idea generation guidance <span class="dim">(optional)</span></label>
22
+ <textarea name="context" id="context" rows="2" placeholder="e.g. Focus on X, avoid approach Y, build on results from previous sprints..."></textarea>
23
+ </div>
24
+ <details style="margin-bottom:0.75rem">
25
+ <summary class="dim" style="cursor:pointer;font-size:0.85rem">Resource overrides per sprint (optional)</summary>
26
+ <div style="display:flex;gap:0.75rem;margin-top:0.5rem;flex-wrap:wrap">
27
+ <div class="form-group" style="margin:0">
28
+ <label for="gpu" style="font-size:0.8rem">GPU</label>
29
+ <input type="text" name="gpu" id="gpu" placeholder="e.g. gpu:1" style="width:140px;font-size:0.85rem">
30
+ </div>
31
+ <div class="form-group" style="margin:0">
32
+ <label for="mem" style="font-size:0.8rem">Memory</label>
33
+ <input type="text" name="mem" id="mem" placeholder="e.g. 64G" style="width:80px;font-size:0.85rem">
34
+ </div>
35
+ <div class="form-group" style="margin:0">
36
+ <label for="cpus" style="font-size:0.8rem">CPUs</label>
37
+ <input type="text" name="cpus" id="cpus" placeholder="e.g. 8" style="width:60px;font-size:0.85rem">
38
+ </div>
39
+ </div>
40
+ </details>
41
+ <button type="submit" class="btn">Start loop</button>
42
+ </form>
43
+ </div>
44
+
45
+ <h3>Loops</h3>
46
+ <table>
47
+ <thead><tr><th>ID</th><th>Study</th><th>Status</th><th>Progress</th><th>Current Sprint</th><th>Created</th></tr></thead>
48
+ <tbody>
49
+ {% for lp in loops %}
50
+ <tr>
51
+ <td><a href="/dashboard/loops/{{ lp.id }}">{{ lp.id }}</a></td>
52
+ <td><a href="/dashboard/studies/{{ lp.study_name }}">{{ lp.study_name }}</a></td>
53
+ <td><span class="badge badge-{{ lp.status }}">{{ lp.status }}</span></td>
54
+ <td>{{ lp.completed_count }}/{{ lp.total_count }}</td>
55
+ <td>{% if lp.current_sprint_id %}<a href="/dashboard/sprints/{{ lp.current_sprint_id }}">{{ lp.current_sprint_id }}</a>{% else %}—{% endif %}</td>
56
+ <td class="dim">{{ lp.created_at }}</td>
57
+ </tr>
58
+ {% endfor %}
59
+ </tbody>
60
+ </table>
61
+ {% endblock %}
@@ -0,0 +1,14 @@
1
+ {% extends "base.html" %}
2
+ {% block title %}Setup — ResearchLoop{% endblock %}
3
+ {% block content %}
4
+ <div class="login-box">
5
+ <h2>Welcome to ResearchLoop</h2>
6
+ <p style="color: #94a3b8; margin-bottom: 1rem;">Set a password to secure your dashboard.</p>
7
+ {% if error %}<p class="error">{{ error }}</p>{% endif %}
8
+ <form method="post" action="/dashboard/setup">
9
+ <input type="password" name="password" placeholder="Password" autofocus required minlength="8">
10
+ <input type="password" name="confirm" placeholder="Confirm password" required minlength="8">
11
+ <button type="submit">Set password</button>
12
+ </form>
13
+ </div>
14
+ {% endblock %}
@@ -0,0 +1,109 @@
1
+ {% extends "base.html" %}
2
+ {% block title %}{{ sprint.id }} — ResearchLoop{% endblock %}
3
+ {% block head %}
4
+ {% if sprint.status.startswith('running') or sprint.status == 'submitted' %}
5
+ <script>setTimeout(function(){refreshSprint()},30000);</script>
6
+ {% endif %}
7
+ {% endblock %}
8
+ {% block content %}
9
+ <h2>Sprint {{ sprint.id }}</h2>
10
+ <div class="card">
11
+ <dl>
12
+ <dt>Study</dt><dd><a href="/dashboard/studies/{{ sprint.study_name }}">{{ sprint.study_name }}</a></dd>
13
+ <dt>Status</dt><dd id="sprint-status"><span class="badge badge-{{ sprint.status.split(' ')[0] }}">{{ sprint.status }}</span></dd>
14
+ <dt>Idea</dt><dd id="sprint-idea">{% if sprint.idea %}<div class="report-content">{{ sprint.idea|markdown|safe }}</div>{% elif sprint.loop_id and sprint.status not in ('completed', 'failed', 'cancelled') %}<span class="dim" style="font-style:italic">auto-generating idea...</span>{% elif sprint.summary %}<span class="dim">{{ sprint.summary[:120] }}{% if sprint.summary|length > 120 %}…{% endif %}</span>{% else %}—{% endif %}</dd>
15
+ {% if sprint.loop_id %}<dt>Loop</dt><dd><a href="/dashboard/loops/{{ sprint.loop_id }}">{{ sprint.loop_id }}</a></dd>{% endif %}
16
+ <dt>Job ID</dt><dd>{{ sprint.job_id or '—' }}</dd>
17
+ <dt>Directory</dt><dd class="dim">{{ sprint.directory or '—' }}</dd>
18
+ <dt>Created</dt><dd>{{ sprint.created_at }}</dd>
19
+ <dt>Started</dt><dd id="sprint-started">{{ sprint.started_at or '—' }}</dd>
20
+ <dt>Completed</dt><dd id="sprint-completed">{{ sprint.completed_at or '—' }}</dd>
21
+ </dl>
22
+ <div class="actions" style="justify-content: space-between;">
23
+ <div style="display: flex; gap: 0.5rem;">
24
+ <button type="button" id="refresh-btn" class="btn btn-sm" onclick="refreshSprint()">&#8635; Refresh status</button>
25
+ {% if sprint.status not in ('completed', 'failed', 'cancelled') %}
26
+ <form method="post" action="/dashboard/sprints/{{ sprint.id }}/cancel">
27
+ <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
28
+ <button type="submit" class="btn btn-danger btn-sm" onclick="return confirm('Cancel this sprint?')">Cancel</button>
29
+ </form>
30
+ {% endif %}
31
+ <form method="post" action="/dashboard/sprints/{{ sprint.id }}/delete">
32
+ <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
33
+ <button type="submit" class="btn btn-danger btn-sm" onclick="return confirm('Delete this sprint? This cannot be undone.')">Delete</button>
34
+ </form>
35
+ {% if sprint.status in ('failed', 'cancelled') %}
36
+ <form method="post" action="/dashboard/sprints/{{ sprint.id }}/resubmit" onsubmit="var b=this.querySelector('button');if(b.disabled)return false;b.disabled=true;b.textContent='Resubmitting...';">
37
+ <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
38
+ <button type="submit" class="btn btn-sm">Resubmit</button>
39
+ </form>
40
+ {% endif %}
41
+ {% if sprint.idea %}
42
+ <a href="/dashboard/studies/{{ sprint.study_name }}?idea={{ sprint.idea|urlencode }}" class="btn btn-sm" style="text-decoration:none">Copy</a>
43
+ {% endif %}
44
+ </div>
45
+ {% if has_pdf %}
46
+ <a href="/dashboard/sprints/{{ sprint.id }}/report.pdf" target="_blank" class="btn btn-sm" style="text-decoration: none;">&#128196; Report PDF</a>
47
+ {% endif %}
48
+ </div>
49
+ </div>
50
+ {% if sprint.summary %}
51
+ <h3>Summary</h3>
52
+ <div class="card" id="summary-section"><p>{{ sprint.summary }}</p></div>
53
+ {% endif %}
54
+ {% if report %}
55
+ <h3>Report</h3>
56
+ <div class="card report-content">{{ report|markdown|safe }}</div>
57
+ {% endif %}
58
+ {% if sprint.error %}
59
+ <h3>Log</h3>
60
+ <div class="card"><pre style="white-space: pre-wrap; word-wrap: break-word; color: #94a3b8;">{{ sprint.error }}</pre></div>
61
+ {% endif %}
62
+ {% if artifacts %}
63
+ <h3>Artifacts</h3>
64
+ <table>
65
+ <thead><tr><th>Filename</th><th>Size</th><th>Uploaded</th><th></th></tr></thead>
66
+ <tbody>
67
+ {% for a in artifacts %}
68
+ <tr>
69
+ <td>{{ a.filename }}</td>
70
+ <td class="dim">{% if a.size %}{{ (a.size / 1024)|round(1) }} KB{% endif %}</td>
71
+ <td class="dim">{{ a.uploaded_at }}</td>
72
+ <td><a href="/dashboard/artifacts/{{ a.id }}/download">Download</a></td>
73
+ </tr>
74
+ {% endfor %}
75
+ </tbody>
76
+ </table>
77
+ {% endif %}
78
+ <script>
79
+ var csrfToken='{{ csrf_token }}';
80
+ function refreshSprint(){
81
+ var btn=document.getElementById('refresh-btn');
82
+ btn.innerHTML='<span class="spinning" style="display:inline-block">&#8635;</span> Refreshing...';
83
+ btn.disabled=true;
84
+ fetch('/dashboard/sprints/{{ sprint.id }}/refresh',{method:'POST',headers:{'Accept':'application/json','X-CSRF-Token':csrfToken}})
85
+ .then(function(r){return r.json()})
86
+ .then(function(d){
87
+ if(d.status){
88
+ var s=d.status.split(' ')[0];
89
+ document.getElementById('sprint-status').innerHTML='<span class="badge badge-'+s+'">'+d.status+'</span>';
90
+ }
91
+ if(d.idea){document.getElementById('sprint-idea').textContent=d.idea;}
92
+ if(d.summary){
93
+ var sec=document.getElementById('summary-section');
94
+ if(sec){sec.querySelector('p').textContent=d.summary;}
95
+ }
96
+ if(d.completed_at){document.getElementById('sprint-completed').textContent=d.completed_at;}
97
+ btn.innerHTML='&#10003; Updated';btn.style.color='#86efac';
98
+ setTimeout(function(){btn.innerHTML='&#8635; Refresh status';btn.style.color='';btn.disabled=false;},2000);
99
+ if(d.status && (d.status.startsWith('running') || d.status==='submitted')){
100
+ setTimeout(function(){refreshSprint()},30000);
101
+ }
102
+ })
103
+ .catch(function(){
104
+ btn.innerHTML='&#8635; Refresh status';btn.disabled=false;
105
+ window.location.reload();
106
+ });
107
+ }
108
+ </script>
109
+ {% endblock %}
@@ -0,0 +1,48 @@
1
+ {% extends "base.html" %}
2
+ {% block title %}Sprints — ResearchLoop{% endblock %}
3
+ {% block content %}
4
+ <h2>Sprints</h2>
5
+
6
+ {% if studies %}
7
+ <div class="card">
8
+ <h3 style="margin-top:0">New sprint</h3>
9
+ {% if error %}<p class="error">{{ error }}</p>{% endif %}
10
+ {% if success %}<p class="success">{{ success }}</p>{% endif %}
11
+ <form method="post" action="/dashboard/sprints/new" onsubmit="var b=this.querySelector('button[type=submit]');if(b.disabled)return false;b.disabled=true;b.textContent='Submitting...';">
12
+ <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
13
+ <div class="form-group">
14
+ <label for="study_name">Study</label>
15
+ <select name="study_name" id="study_name" required>
16
+ {% for s in studies %}<option value="{{ s }}">{{ s }}</option>{% endfor %}
17
+ </select>
18
+ </div>
19
+ <div class="form-group">
20
+ <label for="idea">Research idea</label>
21
+ <textarea name="idea" id="idea" rows="3" placeholder="Describe what this sprint should investigate..." required></textarea>
22
+ </div>
23
+ <button type="submit" class="btn">Submit sprint</button>
24
+ </form>
25
+ </div>
26
+ {% endif %}
27
+
28
+ <table>
29
+ <thead><tr><th>ID</th><th>Study</th><th>Status</th><th>Idea</th><th>Loop</th><th>Created</th><th></th></tr></thead>
30
+ <tbody>
31
+ {% for sp in sprints %}
32
+ <tr>
33
+ <td><a href="/dashboard/sprints/{{ sp.id }}">{{ sp.id }}</a></td>
34
+ <td><a href="/dashboard/studies/{{ sp.study_name }}">{{ sp.study_name }}</a></td>
35
+ <td><span class="badge badge-{{ sp.status.split(' ')[0] }}">{{ sp.status.split(' ')[0] }}</span>{% if '(' in sp.status %} <span class="dim" style="font-size:0.75rem">{{ sp.status.split('(')[1].rstrip(')') }}</span>{% endif %}</td>
36
+ <td>{% if sp.idea %}{{ sp.idea[:50] }}{% if sp.idea|length > 50 %}…{% endif %}{% elif sp.loop_id and sp.status not in ('completed', 'failed', 'cancelled') %}<span class="dim" style="font-style:italic">auto-generating idea...</span>{% elif sp.summary %}<span class="dim">{{ sp.summary[:50] }}{% if sp.summary|length > 50 %}…{% endif %}</span>{% endif %}</td>
37
+ <td>{% if sp.loop_id %}<a href="/dashboard/loops/{{ sp.loop_id }}" class="dim" style="font-size:0.85rem">{{ sp.loop_id }}</a>{% endif %}</td>
38
+ <td class="dim">{{ sp.created_at }}</td>
39
+ <td>
40
+ {% if sp.status.startswith('running') or sp.status == 'submitted' %}
41
+ <button type="button" class="refresh-btn" title="Refresh status" onclick="event.stopPropagation();var b=this;var row=this.closest('tr');b.innerHTML='<span class=spinning>&#8635;</span>';fetch('/dashboard/sprints/{{ sp.id }}/refresh',{method:'POST',headers:{'X-CSRF-Token':'{{ csrf_token }}'},redirect:'manual'}).then(function(){b.innerHTML='&#10003;';b.style.color='#86efac';setTimeout(function(){b.innerHTML='&#8635;';b.style.color='';},2000)})">&#8635;</button>
42
+ {% endif %}
43
+ </td>
44
+ </tr>
45
+ {% endfor %}
46
+ </tbody>
47
+ </table>
48
+ {% endblock %}
@@ -0,0 +1,18 @@
1
+ {% extends "base.html" %}
2
+ {% block title %}Studies — ResearchLoop{% endblock %}
3
+ {% block content %}
4
+ <h2>Studies</h2>
5
+ <table>
6
+ <thead><tr><th>Name</th><th>Cluster</th><th>Description</th><th>Sprints</th></tr></thead>
7
+ <tbody>
8
+ {% for s in studies %}
9
+ <tr>
10
+ <td><a href="/dashboard/studies/{{ s.name }}">{{ s.name }}</a></td>
11
+ <td>{{ s.cluster }}</td>
12
+ <td>{{ s.description or '' }}</td>
13
+ <td>{{ s.sprint_count }}</td>
14
+ </tr>
15
+ {% endfor %}
16
+ </tbody>
17
+ </table>
18
+ {% endblock %}
@@ -0,0 +1,64 @@
1
+ {% extends "base.html" %}
2
+ {% block title %}{{ study.name }} — ResearchLoop{% endblock %}
3
+ {% block content %}
4
+ <h2>{{ study.name }}</h2>
5
+ <div class="card">
6
+ <dl>
7
+ <dt>Cluster</dt><dd>{{ study.cluster }}</dd>
8
+ <dt>Description</dt><dd>{{ study.description or '—' }}</dd>
9
+ <dt>Sprints directory</dt><dd class="dim">{{ study.sprints_dir }}</dd>
10
+ </dl>
11
+ </div>
12
+
13
+ <h3>New sprint</h3>
14
+ <div class="card">
15
+ {% if error %}<p class="error">{{ error }}</p>{% endif %}
16
+ {% if success %}<p class="success">{{ success }}</p>{% endif %}
17
+ <form method="post" action="/dashboard/studies/{{ study.name }}/sprint" onsubmit="var b=this.querySelector('button[type=submit]');if(b.disabled)return false;b.disabled=true;b.textContent='Submitting...';">
18
+ <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
19
+ <div class="form-group">
20
+ <label for="idea">Research idea</label>
21
+ <textarea name="idea" id="idea" rows="3" placeholder="Describe what this sprint should investigate..." required>{{ prefill_idea or '' }}</textarea>
22
+ </div>
23
+ <details style="margin-bottom:0.75rem">
24
+ <summary class="dim" style="cursor:pointer;font-size:0.85rem">Resource overrides (optional)</summary>
25
+ <div style="display:flex;gap:0.75rem;margin-top:0.5rem;flex-wrap:wrap">
26
+ <div class="form-group" style="margin:0">
27
+ <label for="gpu" style="font-size:0.8rem">GPU</label>
28
+ <input type="text" name="gpu" id="gpu" placeholder="e.g. gpu:1" style="width:140px;font-size:0.85rem">
29
+ </div>
30
+ <div class="form-group" style="margin:0">
31
+ <label for="mem" style="font-size:0.8rem">Memory</label>
32
+ <input type="text" name="mem" id="mem" placeholder="e.g. 64G" style="width:80px;font-size:0.85rem">
33
+ </div>
34
+ <div class="form-group" style="margin:0">
35
+ <label for="cpus" style="font-size:0.8rem">CPUs</label>
36
+ <input type="text" name="cpus" id="cpus" placeholder="e.g. 8" style="width:60px;font-size:0.85rem">
37
+ </div>
38
+ </div>
39
+ </details>
40
+ <button type="submit" class="btn">Submit sprint</button>
41
+ </form>
42
+ </div>
43
+
44
+ <h3>Recent sprints</h3>
45
+ <table>
46
+ <thead><tr><th>ID</th><th>Status</th><th>Idea</th><th>Loop</th><th>Created</th><th></th></tr></thead>
47
+ <tbody>
48
+ {% for sp in sprints %}
49
+ <tr>
50
+ <td><a href="/dashboard/sprints/{{ sp.id }}">{{ sp.id }}</a></td>
51
+ <td><span class="badge badge-{{ sp.status.split(' ')[0] }}">{{ sp.status.split(' ')[0] }}</span>{% if '(' in sp.status %} <span class="dim" style="font-size:0.75rem">{{ sp.status.split('(')[1].rstrip(')') }}</span>{% endif %}</td>
52
+ <td>{% if sp.idea %}{{ sp.idea[:60] }}{% if sp.idea|length > 60 %}…{% endif %}{% elif sp.loop_id and sp.status not in ('completed', 'failed', 'cancelled') %}<span class="dim" style="font-style:italic">auto-generating idea...</span>{% elif sp.summary %}<span class="dim">{{ sp.summary[:60] }}{% if sp.summary|length > 60 %}…{% endif %}</span>{% endif %}</td>
53
+ <td>{% if sp.loop_id %}<a href="/dashboard/loops/{{ sp.loop_id }}" class="dim" style="font-size:0.85rem">{{ sp.loop_id }}</a>{% endif %}</td>
54
+ <td class="dim">{{ sp.created_at }}</td>
55
+ <td>
56
+ {% if sp.status.startswith('running') or sp.status == 'submitted' %}
57
+ <button type="button" class="refresh-btn" title="Refresh status" onclick="event.stopPropagation();var b=this;b.innerHTML='<span class=spinning>&#8635;</span>';fetch('/dashboard/sprints/{{ sp.id }}/refresh',{method:'POST',headers:{'X-CSRF-Token':'{{ csrf_token }}'},redirect:'manual'}).then(function(){b.innerHTML='&#10003;';b.style.color='#86efac';setTimeout(function(){b.innerHTML='&#8635;';b.style.color='';},2000)})">&#8635;</button>
58
+ {% endif %}
59
+ </td>
60
+ </tr>
61
+ {% endfor %}
62
+ </tbody>
63
+ </table>
64
+ {% endblock %}
@@ -0,0 +1,5 @@
1
+ from . import queries
2
+ from .database import Database
3
+ from .migrations import run_migrations
4
+
5
+ __all__ = ["Database", "run_migrations", "queries"]
@@ -0,0 +1,86 @@
1
+ """Async SQLite database wrapper using aiosqlite with WAL mode."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Sequence
6
+ from typing import Any
7
+
8
+ import aiosqlite
9
+
10
+
11
+ class Database:
12
+ """Lightweight async wrapper around aiosqlite.
13
+
14
+ Enables WAL journal mode for concurrent reads and provides
15
+ convenience helpers for common query patterns. On the first
16
+ call to :meth:`connect`, the schema migrations are executed
17
+ automatically.
18
+ """
19
+
20
+ def __init__(self, db_path: str) -> None:
21
+ self.db_path = db_path
22
+ self._conn: aiosqlite.Connection | None = None
23
+
24
+ # -- lifecycle ------------------------------------------------------------
25
+
26
+ async def connect(self) -> None:
27
+ """Open the database connection, enable WAL mode, and run migrations."""
28
+ if self._conn is not None:
29
+ return
30
+
31
+ self._conn = await aiosqlite.connect(self.db_path)
32
+ self._conn.row_factory = aiosqlite.Row
33
+
34
+ # Enable WAL for better concurrent read performance.
35
+ await self._conn.execute("PRAGMA journal_mode=WAL")
36
+ # Enforce foreign-key constraints.
37
+ await self._conn.execute("PRAGMA foreign_keys=ON")
38
+
39
+ # Auto-initialize schema on first connect.
40
+ from .migrations import run_migrations
41
+
42
+ await run_migrations(self)
43
+
44
+ async def close(self) -> None:
45
+ """Close the database connection."""
46
+ if self._conn is not None:
47
+ await self._conn.close()
48
+ self._conn = None
49
+
50
+ # -- async context manager ------------------------------------------------
51
+
52
+ async def __aenter__(self) -> Database:
53
+ await self.connect()
54
+ return self
55
+
56
+ async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
57
+ await self.close()
58
+
59
+ # -- query helpers --------------------------------------------------------
60
+
61
+ async def execute(self, sql: str, params: Sequence[Any] = ()) -> aiosqlite.Cursor:
62
+ """Execute a single SQL statement and commit."""
63
+ assert self._conn is not None, "Database is not connected"
64
+ cursor = await self._conn.execute(sql, params)
65
+ await self._conn.commit()
66
+ return cursor
67
+
68
+ async def fetch_one(
69
+ self, sql: str, params: Sequence[Any] = ()
70
+ ) -> dict[str, Any] | None:
71
+ """Execute *sql* and return the first row as a dict, or ``None``."""
72
+ assert self._conn is not None, "Database is not connected"
73
+ cursor = await self._conn.execute(sql, params)
74
+ row = await cursor.fetchone()
75
+ if row is None:
76
+ return None
77
+ return dict(row)
78
+
79
+ async def fetch_all(
80
+ self, sql: str, params: Sequence[Any] = ()
81
+ ) -> list[dict[str, Any]]:
82
+ """Execute *sql* and return all rows as a list of dicts."""
83
+ assert self._conn is not None, "Database is not connected"
84
+ cursor = await self._conn.execute(sql, params)
85
+ rows = await cursor.fetchall()
86
+ return [dict(r) for r in rows]