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,172 @@
|
|
|
1
|
+
"""Schema creation and index definitions for the researchloop database."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from .database import Database
|
|
9
|
+
|
|
10
|
+
SCHEMA_SQL = """
|
|
11
|
+
CREATE TABLE IF NOT EXISTS studies (
|
|
12
|
+
name TEXT PRIMARY KEY,
|
|
13
|
+
cluster TEXT NOT NULL,
|
|
14
|
+
description TEXT,
|
|
15
|
+
claude_md_path TEXT,
|
|
16
|
+
sprints_dir TEXT NOT NULL,
|
|
17
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
18
|
+
config_json TEXT -- full StudyConfig as JSON
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
CREATE TABLE IF NOT EXISTS sprints (
|
|
22
|
+
id TEXT PRIMARY KEY, -- e.g. "sp-a3f7b2"
|
|
23
|
+
study_name TEXT NOT NULL REFERENCES studies(name),
|
|
24
|
+
idea TEXT,
|
|
25
|
+
status TEXT NOT NULL DEFAULT 'pending',
|
|
26
|
+
job_id TEXT, -- SLURM/SGE job ID
|
|
27
|
+
directory TEXT, -- full path on cluster
|
|
28
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
29
|
+
started_at TEXT,
|
|
30
|
+
completed_at TEXT,
|
|
31
|
+
error TEXT,
|
|
32
|
+
summary TEXT,
|
|
33
|
+
session_id TEXT, -- claude session ID for --resume
|
|
34
|
+
webhook_token TEXT, -- per-sprint token for webhook auth
|
|
35
|
+
loop_id TEXT, -- auto-loop ID if part of a loop
|
|
36
|
+
metadata_json TEXT,
|
|
37
|
+
FOREIGN KEY (study_name) REFERENCES studies(name)
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
CREATE TABLE IF NOT EXISTS auto_loops (
|
|
41
|
+
id TEXT PRIMARY KEY,
|
|
42
|
+
study_name TEXT NOT NULL,
|
|
43
|
+
total_count INTEGER NOT NULL,
|
|
44
|
+
completed_count INTEGER NOT NULL DEFAULT 0,
|
|
45
|
+
current_sprint_id TEXT,
|
|
46
|
+
status TEXT NOT NULL DEFAULT 'running', -- running, completed, stopped, failed
|
|
47
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
48
|
+
stopped_at TEXT,
|
|
49
|
+
metadata_json TEXT,
|
|
50
|
+
FOREIGN KEY (study_name) REFERENCES studies(name)
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
CREATE TABLE IF NOT EXISTS artifacts (
|
|
54
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
55
|
+
sprint_id TEXT NOT NULL,
|
|
56
|
+
filename TEXT NOT NULL,
|
|
57
|
+
path TEXT NOT NULL, -- local storage path
|
|
58
|
+
size INTEGER,
|
|
59
|
+
content_type TEXT,
|
|
60
|
+
uploaded_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
61
|
+
FOREIGN KEY (sprint_id) REFERENCES sprints(id)
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
CREATE TABLE IF NOT EXISTS slack_sessions (
|
|
65
|
+
thread_ts TEXT PRIMARY KEY,
|
|
66
|
+
sprint_id TEXT,
|
|
67
|
+
session_id TEXT, -- claude --resume session ID
|
|
68
|
+
study_name TEXT,
|
|
69
|
+
messages_json TEXT, -- JSON array of {role, text}
|
|
70
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
71
|
+
FOREIGN KEY (sprint_id) REFERENCES sprints(id)
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
CREATE TABLE IF NOT EXISTS events (
|
|
75
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
76
|
+
sprint_id TEXT,
|
|
77
|
+
event_type TEXT NOT NULL,
|
|
78
|
+
data_json TEXT,
|
|
79
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
80
|
+
FOREIGN KEY (sprint_id) REFERENCES sprints(id)
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
CREATE TABLE IF NOT EXISTS settings (
|
|
84
|
+
key TEXT PRIMARY KEY,
|
|
85
|
+
value TEXT NOT NULL
|
|
86
|
+
);
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
INDEXES_SQL = """
|
|
90
|
+
CREATE INDEX IF NOT EXISTS idx_sprints_study_name ON sprints(study_name);
|
|
91
|
+
CREATE INDEX IF NOT EXISTS idx_sprints_status ON sprints(status);
|
|
92
|
+
CREATE INDEX IF NOT EXISTS idx_events_sprint_id ON events(sprint_id);
|
|
93
|
+
CREATE INDEX IF NOT EXISTS idx_artifacts_sprint_id ON artifacts(sprint_id);
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
async def _add_column_if_missing(
|
|
98
|
+
db: Database,
|
|
99
|
+
table: str,
|
|
100
|
+
column: str,
|
|
101
|
+
col_type: str = "TEXT",
|
|
102
|
+
) -> None:
|
|
103
|
+
"""Add a column to a table if it doesn't exist."""
|
|
104
|
+
assert db._conn is not None
|
|
105
|
+
cursor = await db._conn.execute(f"PRAGMA table_info({table})")
|
|
106
|
+
rows = await cursor.fetchall()
|
|
107
|
+
existing = {row[1] for row in rows}
|
|
108
|
+
if column not in existing:
|
|
109
|
+
await db._conn.execute(f"ALTER TABLE {table} ADD COLUMN {column} {col_type}")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
async def run_migrations(db: Database) -> None:
|
|
113
|
+
"""Create all tables and indexes if they do not already exist."""
|
|
114
|
+
assert db._conn is not None, "Database must be connected before running migrations"
|
|
115
|
+
await db._conn.executescript(SCHEMA_SQL + INDEXES_SQL)
|
|
116
|
+
|
|
117
|
+
# Incremental column migrations for existing databases.
|
|
118
|
+
await _add_column_if_missing(db, "sprints", "webhook_token", "TEXT")
|
|
119
|
+
await _add_column_if_missing(db, "sprints", "loop_id", "TEXT")
|
|
120
|
+
await _add_column_if_missing(db, "auto_loops", "metadata_json", "TEXT")
|
|
121
|
+
await _add_column_if_missing(db, "slack_sessions", "messages_json", "TEXT")
|
|
122
|
+
|
|
123
|
+
# Make idea column nullable — already applied, skip on future runs.
|
|
124
|
+
# The migration checks if the column is still NOT NULL before acting.
|
|
125
|
+
await _make_idea_nullable(db)
|
|
126
|
+
|
|
127
|
+
await db._conn.commit()
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
async def _make_idea_nullable(db: Database) -> None:
|
|
131
|
+
"""Remove NOT NULL from sprints.idea if needed."""
|
|
132
|
+
assert db._conn is not None
|
|
133
|
+
|
|
134
|
+
# Recover from a previously failed migration.
|
|
135
|
+
tables = await db._conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
|
|
136
|
+
table_names = {r[0] for r in await tables.fetchall()}
|
|
137
|
+
if "_sprints_old" in table_names:
|
|
138
|
+
await db._conn.execute("PRAGMA foreign_keys=OFF")
|
|
139
|
+
if "sprints" not in table_names:
|
|
140
|
+
await db._conn.execute("ALTER TABLE _sprints_old RENAME TO sprints")
|
|
141
|
+
else:
|
|
142
|
+
# Keep whichever has more rows.
|
|
143
|
+
old_cursor = await db._conn.execute("SELECT count(*) FROM _sprints_old")
|
|
144
|
+
old_n = await old_cursor.fetchone()
|
|
145
|
+
new_cursor = await db._conn.execute("SELECT count(*) FROM sprints")
|
|
146
|
+
new_n = await new_cursor.fetchone()
|
|
147
|
+
old_count = old_n[0] if old_n else 0
|
|
148
|
+
new_count = new_n[0] if new_n else 0
|
|
149
|
+
if old_count > new_count:
|
|
150
|
+
await db._conn.execute("DROP TABLE sprints")
|
|
151
|
+
await db._conn.execute("ALTER TABLE _sprints_old RENAME TO sprints")
|
|
152
|
+
else:
|
|
153
|
+
await db._conn.execute("DROP TABLE _sprints_old")
|
|
154
|
+
await db._conn.execute("PRAGMA foreign_keys=ON")
|
|
155
|
+
|
|
156
|
+
cursor = await db._conn.execute("PRAGMA table_info(sprints)")
|
|
157
|
+
rows = await cursor.fetchall()
|
|
158
|
+
for row in rows:
|
|
159
|
+
if row[1] == "idea" and row[3] == 1: # notnull=1
|
|
160
|
+
# Disable FK checks during rebuild.
|
|
161
|
+
await db._conn.execute("PRAGMA foreign_keys=OFF")
|
|
162
|
+
# Rebuild: rename → create new → copy → drop old.
|
|
163
|
+
await db._conn.execute("ALTER TABLE sprints RENAME TO _sprints_old")
|
|
164
|
+
for stmt in SCHEMA_SQL.strip().split(";"):
|
|
165
|
+
stmt = stmt.strip()
|
|
166
|
+
if stmt.startswith("CREATE TABLE IF NOT EXISTS sprints"):
|
|
167
|
+
await db._conn.execute(stmt)
|
|
168
|
+
break
|
|
169
|
+
await db._conn.execute("INSERT INTO sprints SELECT * FROM _sprints_old")
|
|
170
|
+
await db._conn.execute("DROP TABLE _sprints_old")
|
|
171
|
+
await db._conn.execute("PRAGMA foreign_keys=ON")
|
|
172
|
+
return
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
"""Async query functions for the researchloop database.
|
|
2
|
+
|
|
3
|
+
Every public function takes a :class:`Database` instance as its first
|
|
4
|
+
argument and returns plain ``dict`` rows (converted from ``aiosqlite.Row``).
|
|
5
|
+
All queries use parameterized placeholders to prevent SQL injection.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import TYPE_CHECKING, Any
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from .database import Database
|
|
14
|
+
|
|
15
|
+
# ---------------------------------------------------------------------------
|
|
16
|
+
# Column whitelists — prevent SQL injection via dynamic column names
|
|
17
|
+
# ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
_STUDY_COLUMNS: frozenset[str] = frozenset(
|
|
20
|
+
{
|
|
21
|
+
"name",
|
|
22
|
+
"cluster",
|
|
23
|
+
"description",
|
|
24
|
+
"claude_md_path",
|
|
25
|
+
"sprints_dir",
|
|
26
|
+
"created_at",
|
|
27
|
+
"config_json",
|
|
28
|
+
}
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
_SPRINT_COLUMNS: frozenset[str] = frozenset(
|
|
32
|
+
{
|
|
33
|
+
"id",
|
|
34
|
+
"study_name",
|
|
35
|
+
"idea",
|
|
36
|
+
"status",
|
|
37
|
+
"job_id",
|
|
38
|
+
"directory",
|
|
39
|
+
"created_at",
|
|
40
|
+
"started_at",
|
|
41
|
+
"completed_at",
|
|
42
|
+
"error",
|
|
43
|
+
"summary",
|
|
44
|
+
"session_id",
|
|
45
|
+
"webhook_token",
|
|
46
|
+
"loop_id",
|
|
47
|
+
"metadata_json",
|
|
48
|
+
}
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
_AUTO_LOOP_COLUMNS: frozenset[str] = frozenset(
|
|
52
|
+
{
|
|
53
|
+
"id",
|
|
54
|
+
"study_name",
|
|
55
|
+
"total_count",
|
|
56
|
+
"completed_count",
|
|
57
|
+
"current_sprint_id",
|
|
58
|
+
"status",
|
|
59
|
+
"created_at",
|
|
60
|
+
"stopped_at",
|
|
61
|
+
"metadata_json",
|
|
62
|
+
}
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _validate_columns(
|
|
67
|
+
kwargs: dict[str, Any], allowed: frozenset[str], entity: str
|
|
68
|
+
) -> None:
|
|
69
|
+
"""Raise ValueError if any key in *kwargs* is not in *allowed*."""
|
|
70
|
+
invalid = set(kwargs.keys()) - allowed
|
|
71
|
+
if invalid:
|
|
72
|
+
raise ValueError(
|
|
73
|
+
f"Invalid column(s) for {entity}: {', '.join(sorted(invalid))}"
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# ---------------------------------------------------------------------------
|
|
78
|
+
# Studies
|
|
79
|
+
# ---------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
async def create_study(
|
|
83
|
+
db: Database,
|
|
84
|
+
name: str,
|
|
85
|
+
cluster: str,
|
|
86
|
+
description: str | None,
|
|
87
|
+
claude_md_path: str | None,
|
|
88
|
+
sprints_dir: str,
|
|
89
|
+
config_json: str | None = None,
|
|
90
|
+
) -> dict[str, Any]:
|
|
91
|
+
"""Insert a new study and return it."""
|
|
92
|
+
await db.execute(
|
|
93
|
+
"""
|
|
94
|
+
INSERT INTO studies
|
|
95
|
+
(name, cluster, description, claude_md_path,
|
|
96
|
+
sprints_dir, config_json)
|
|
97
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
98
|
+
""",
|
|
99
|
+
(name, cluster, description, claude_md_path, sprints_dir, config_json),
|
|
100
|
+
)
|
|
101
|
+
return await get_study(db, name) # type: ignore[return-value]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
async def get_study(db: Database, name: str) -> dict[str, Any] | None:
|
|
105
|
+
"""Return a single study by name, or ``None``."""
|
|
106
|
+
return await db.fetch_one("SELECT * FROM studies WHERE name = ?", (name,))
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
async def list_studies(db: Database) -> list[dict[str, Any]]:
|
|
110
|
+
"""Return all studies ordered by creation time (newest first)."""
|
|
111
|
+
return await db.fetch_all("SELECT * FROM studies ORDER BY created_at DESC")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
async def update_study(db: Database, name: str, **kwargs: Any) -> dict[str, Any] | None:
|
|
115
|
+
"""Update arbitrary columns on a study. Returns the updated row."""
|
|
116
|
+
if not kwargs:
|
|
117
|
+
return await get_study(db, name)
|
|
118
|
+
_validate_columns(kwargs, _STUDY_COLUMNS, "study")
|
|
119
|
+
columns = ", ".join(f"{k} = ?" for k in kwargs)
|
|
120
|
+
values = list(kwargs.values())
|
|
121
|
+
values.append(name)
|
|
122
|
+
await db.execute(
|
|
123
|
+
f"UPDATE studies SET {columns} WHERE name = ?",
|
|
124
|
+
values,
|
|
125
|
+
)
|
|
126
|
+
return await get_study(db, name)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# ---------------------------------------------------------------------------
|
|
130
|
+
# Sprints
|
|
131
|
+
# ---------------------------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
async def create_sprint(
|
|
135
|
+
db: Database,
|
|
136
|
+
id: str,
|
|
137
|
+
study_name: str,
|
|
138
|
+
idea: str | None = None,
|
|
139
|
+
directory: str | None = None,
|
|
140
|
+
) -> dict[str, Any]:
|
|
141
|
+
"""Insert a new sprint and return it."""
|
|
142
|
+
import secrets
|
|
143
|
+
|
|
144
|
+
webhook_token = secrets.token_hex(16)
|
|
145
|
+
await db.execute(
|
|
146
|
+
"""
|
|
147
|
+
INSERT INTO sprints (id, study_name, idea, directory, webhook_token)
|
|
148
|
+
VALUES (?, ?, ?, ?, ?)
|
|
149
|
+
""",
|
|
150
|
+
(id, study_name, idea, directory, webhook_token),
|
|
151
|
+
)
|
|
152
|
+
return await get_sprint(db, id) # type: ignore[return-value]
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
async def get_sprint(db: Database, id: str) -> dict[str, Any] | None:
|
|
156
|
+
"""Return a single sprint by id, or ``None``."""
|
|
157
|
+
return await db.fetch_one("SELECT * FROM sprints WHERE id = ?", (id,))
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
async def list_sprints(
|
|
161
|
+
db: Database,
|
|
162
|
+
study_name: str | None = None,
|
|
163
|
+
status: str | None = None,
|
|
164
|
+
limit: int = 50,
|
|
165
|
+
) -> list[dict[str, Any]]:
|
|
166
|
+
"""Return sprints with optional filters, newest first."""
|
|
167
|
+
clauses: list[str] = []
|
|
168
|
+
params: list[Any] = []
|
|
169
|
+
|
|
170
|
+
if study_name is not None:
|
|
171
|
+
clauses.append("study_name = ?")
|
|
172
|
+
params.append(study_name)
|
|
173
|
+
if status is not None:
|
|
174
|
+
clauses.append("status = ?")
|
|
175
|
+
params.append(status)
|
|
176
|
+
|
|
177
|
+
where = ("WHERE " + " AND ".join(clauses)) if clauses else ""
|
|
178
|
+
params.append(limit)
|
|
179
|
+
|
|
180
|
+
return await db.fetch_all(
|
|
181
|
+
f"SELECT * FROM sprints {where} ORDER BY created_at DESC LIMIT ?",
|
|
182
|
+
params,
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
async def update_sprint(db: Database, id: str, **kwargs: Any) -> dict[str, Any] | None:
|
|
187
|
+
"""Update arbitrary columns on a sprint. Returns the updated row."""
|
|
188
|
+
if not kwargs:
|
|
189
|
+
return await get_sprint(db, id)
|
|
190
|
+
_validate_columns(kwargs, _SPRINT_COLUMNS, "sprint")
|
|
191
|
+
columns = ", ".join(f"{k} = ?" for k in kwargs)
|
|
192
|
+
values = list(kwargs.values())
|
|
193
|
+
values.append(id)
|
|
194
|
+
await db.execute(
|
|
195
|
+
f"UPDATE sprints SET {columns} WHERE id = ?",
|
|
196
|
+
values,
|
|
197
|
+
)
|
|
198
|
+
return await get_sprint(db, id)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
async def delete_sprint(db: Database, id: str) -> None:
|
|
202
|
+
"""Delete a sprint and its related artifacts, events, and sessions."""
|
|
203
|
+
await db.execute("DELETE FROM artifacts WHERE sprint_id = ?", [id])
|
|
204
|
+
await db.execute("DELETE FROM events WHERE sprint_id = ?", [id])
|
|
205
|
+
await db.execute("DELETE FROM slack_sessions WHERE sprint_id = ?", [id])
|
|
206
|
+
await db.execute("DELETE FROM sprints WHERE id = ?", [id])
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
async def get_active_sprints(db: Database) -> list[dict[str, Any]]:
|
|
210
|
+
"""Return all sprints whose status is 'running'."""
|
|
211
|
+
return await db.fetch_all(
|
|
212
|
+
"SELECT * FROM sprints WHERE status = 'running' ORDER BY created_at DESC",
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
# ---------------------------------------------------------------------------
|
|
217
|
+
# Auto-loops
|
|
218
|
+
# ---------------------------------------------------------------------------
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
async def create_auto_loop(
|
|
222
|
+
db: Database,
|
|
223
|
+
id: str,
|
|
224
|
+
study_name: str,
|
|
225
|
+
total_count: int,
|
|
226
|
+
) -> dict[str, Any]:
|
|
227
|
+
"""Insert a new auto-loop and return it."""
|
|
228
|
+
await db.execute(
|
|
229
|
+
"""
|
|
230
|
+
INSERT INTO auto_loops (id, study_name, total_count)
|
|
231
|
+
VALUES (?, ?, ?)
|
|
232
|
+
""",
|
|
233
|
+
(id, study_name, total_count),
|
|
234
|
+
)
|
|
235
|
+
return await get_auto_loop(db, id) # type: ignore[return-value]
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
async def get_auto_loop(db: Database, id: str) -> dict[str, Any] | None:
|
|
239
|
+
"""Return a single auto-loop by id, or ``None``."""
|
|
240
|
+
return await db.fetch_one("SELECT * FROM auto_loops WHERE id = ?", (id,))
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
async def list_auto_loops(
|
|
244
|
+
db: Database,
|
|
245
|
+
study_name: str | None = None,
|
|
246
|
+
) -> list[dict[str, Any]]:
|
|
247
|
+
"""Return auto-loops, optionally filtered by study name."""
|
|
248
|
+
if study_name is not None:
|
|
249
|
+
return await db.fetch_all(
|
|
250
|
+
"SELECT * FROM auto_loops WHERE study_name = ? ORDER BY created_at DESC",
|
|
251
|
+
(study_name,),
|
|
252
|
+
)
|
|
253
|
+
return await db.fetch_all(
|
|
254
|
+
"SELECT * FROM auto_loops ORDER BY created_at DESC",
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
async def update_auto_loop(
|
|
259
|
+
db: Database, id: str, **kwargs: Any
|
|
260
|
+
) -> dict[str, Any] | None:
|
|
261
|
+
"""Update arbitrary columns on an auto-loop. Returns the updated row."""
|
|
262
|
+
if not kwargs:
|
|
263
|
+
return await get_auto_loop(db, id)
|
|
264
|
+
_validate_columns(kwargs, _AUTO_LOOP_COLUMNS, "auto_loop")
|
|
265
|
+
columns = ", ".join(f"{k} = ?" for k in kwargs)
|
|
266
|
+
values = list(kwargs.values())
|
|
267
|
+
values.append(id)
|
|
268
|
+
await db.execute(
|
|
269
|
+
f"UPDATE auto_loops SET {columns} WHERE id = ?",
|
|
270
|
+
values,
|
|
271
|
+
)
|
|
272
|
+
return await get_auto_loop(db, id)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
# ---------------------------------------------------------------------------
|
|
276
|
+
# Artifacts
|
|
277
|
+
# ---------------------------------------------------------------------------
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
async def create_artifact(
|
|
281
|
+
db: Database,
|
|
282
|
+
sprint_id: str,
|
|
283
|
+
filename: str,
|
|
284
|
+
path: str,
|
|
285
|
+
size: int | None = None,
|
|
286
|
+
content_type: str | None = None,
|
|
287
|
+
) -> dict[str, Any]:
|
|
288
|
+
"""Insert a new artifact and return it."""
|
|
289
|
+
cursor = await db.execute(
|
|
290
|
+
"""
|
|
291
|
+
INSERT INTO artifacts (sprint_id, filename, path, size, content_type)
|
|
292
|
+
VALUES (?, ?, ?, ?, ?)
|
|
293
|
+
""",
|
|
294
|
+
(sprint_id, filename, path, size, content_type),
|
|
295
|
+
)
|
|
296
|
+
row_id = cursor.lastrowid
|
|
297
|
+
return await get_artifact(db, row_id) # type: ignore[return-value]
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
async def list_artifacts(db: Database, sprint_id: str) -> list[dict[str, Any]]:
|
|
301
|
+
"""Return all artifacts for a given sprint."""
|
|
302
|
+
return await db.fetch_all(
|
|
303
|
+
"SELECT * FROM artifacts WHERE sprint_id = ? ORDER BY uploaded_at DESC",
|
|
304
|
+
(sprint_id,),
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
async def get_artifact(db: Database, id: int) -> dict[str, Any] | None:
|
|
309
|
+
"""Return a single artifact by id, or ``None``."""
|
|
310
|
+
return await db.fetch_one("SELECT * FROM artifacts WHERE id = ?", (id,))
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
# ---------------------------------------------------------------------------
|
|
314
|
+
# Events
|
|
315
|
+
# ---------------------------------------------------------------------------
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
async def create_event(
|
|
319
|
+
db: Database,
|
|
320
|
+
sprint_id: str | None,
|
|
321
|
+
event_type: str,
|
|
322
|
+
data_json: str | None = None,
|
|
323
|
+
) -> dict[str, Any]:
|
|
324
|
+
"""Insert a new event and return it."""
|
|
325
|
+
cursor = await db.execute(
|
|
326
|
+
"""
|
|
327
|
+
INSERT INTO events (sprint_id, event_type, data_json)
|
|
328
|
+
VALUES (?, ?, ?)
|
|
329
|
+
""",
|
|
330
|
+
(sprint_id, event_type, data_json),
|
|
331
|
+
)
|
|
332
|
+
row_id = cursor.lastrowid
|
|
333
|
+
result = await db.fetch_one("SELECT * FROM events WHERE id = ?", (row_id,))
|
|
334
|
+
return result # type: ignore[return-value]
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
async def list_events(
|
|
338
|
+
db: Database,
|
|
339
|
+
sprint_id: str | None = None,
|
|
340
|
+
limit: int = 100,
|
|
341
|
+
) -> list[dict[str, Any]]:
|
|
342
|
+
"""Return events, optionally filtered by sprint id, newest first."""
|
|
343
|
+
if sprint_id is not None:
|
|
344
|
+
return await db.fetch_all(
|
|
345
|
+
"SELECT * FROM events WHERE sprint_id = ? ORDER BY created_at DESC LIMIT ?",
|
|
346
|
+
(sprint_id, limit),
|
|
347
|
+
)
|
|
348
|
+
return await db.fetch_all(
|
|
349
|
+
"SELECT * FROM events ORDER BY created_at DESC LIMIT ?",
|
|
350
|
+
(limit,),
|
|
351
|
+
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Sprint runner - executes inside SLURM/SGE jobs on HPC clusters."""
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""Claude CLI wrapper for running sub-agent prompts.
|
|
2
|
+
|
|
3
|
+
Invokes ``claude -p "..." --output-format json`` as a subprocess and
|
|
4
|
+
parses the structured output.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
import os
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
import jinja2
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
# Template directory lives alongside this module.
|
|
20
|
+
_TEMPLATES_DIR = Path(__file__).resolve().parent / "templates"
|
|
21
|
+
|
|
22
|
+
_jinja_env = jinja2.Environment(
|
|
23
|
+
loader=jinja2.FileSystemLoader(str(_TEMPLATES_DIR)),
|
|
24
|
+
keep_trailing_newline=True,
|
|
25
|
+
undefined=jinja2.StrictUndefined,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def render_template(template_name: str, **kwargs: object) -> str:
|
|
30
|
+
"""Load a Jinja2 template from the ``templates/`` directory and render it."""
|
|
31
|
+
template = _jinja_env.get_template(template_name)
|
|
32
|
+
return template.render(**kwargs)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
async def run_claude(
|
|
36
|
+
prompt: str,
|
|
37
|
+
working_dir: str,
|
|
38
|
+
claude_md: str | None = None,
|
|
39
|
+
session_id: str | None = None,
|
|
40
|
+
timeout: int = 3600,
|
|
41
|
+
claude_command: str = "claude --dangerously-skip-permissions",
|
|
42
|
+
) -> tuple[str, str | None]:
|
|
43
|
+
"""Run the Claude CLI with the given prompt.
|
|
44
|
+
|
|
45
|
+
Parameters
|
|
46
|
+
----------
|
|
47
|
+
prompt:
|
|
48
|
+
The full prompt text to send.
|
|
49
|
+
working_dir:
|
|
50
|
+
The working directory in which Claude should operate.
|
|
51
|
+
claude_md:
|
|
52
|
+
Optional path to a CLAUDE.md file. When provided the
|
|
53
|
+
``CLAUDE_MD`` environment variable is set so the CLI picks it up.
|
|
54
|
+
session_id:
|
|
55
|
+
If continuing a conversation, the session ID from a previous call.
|
|
56
|
+
timeout:
|
|
57
|
+
Maximum seconds to wait for the process (default 3600 = 1 hour).
|
|
58
|
+
|
|
59
|
+
Returns
|
|
60
|
+
-------
|
|
61
|
+
tuple[str, str | None]
|
|
62
|
+
``(output_text, session_id)`` parsed from the JSON output.
|
|
63
|
+
"""
|
|
64
|
+
# Split the command string to support things like
|
|
65
|
+
# "singularity exec img.sif claude --dangerously-skip-permissions"
|
|
66
|
+
base_cmd = claude_command.split()
|
|
67
|
+
cmd: list[str] = [
|
|
68
|
+
*base_cmd,
|
|
69
|
+
"-p",
|
|
70
|
+
prompt,
|
|
71
|
+
"--output-format",
|
|
72
|
+
"json",
|
|
73
|
+
]
|
|
74
|
+
|
|
75
|
+
if session_id:
|
|
76
|
+
cmd.extend(["--resume", session_id])
|
|
77
|
+
|
|
78
|
+
# Build environment with optional CLAUDE_MD override.
|
|
79
|
+
env = os.environ.copy()
|
|
80
|
+
if claude_md:
|
|
81
|
+
env["CLAUDE_MD"] = claude_md
|
|
82
|
+
|
|
83
|
+
logger.info(
|
|
84
|
+
"Running Claude CLI (session=%s, timeout=%ds, cwd=%s)",
|
|
85
|
+
session_id or "new",
|
|
86
|
+
timeout,
|
|
87
|
+
working_dir,
|
|
88
|
+
)
|
|
89
|
+
logger.debug("Prompt length: %d chars", len(prompt))
|
|
90
|
+
|
|
91
|
+
try:
|
|
92
|
+
process = await asyncio.create_subprocess_exec(
|
|
93
|
+
*cmd,
|
|
94
|
+
stdout=asyncio.subprocess.PIPE,
|
|
95
|
+
stderr=asyncio.subprocess.PIPE,
|
|
96
|
+
cwd=working_dir,
|
|
97
|
+
env=env,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
stdout_bytes, stderr_bytes = await asyncio.wait_for(
|
|
101
|
+
process.communicate(), timeout=timeout
|
|
102
|
+
)
|
|
103
|
+
except asyncio.TimeoutError:
|
|
104
|
+
logger.error("Claude CLI timed out after %ds", timeout)
|
|
105
|
+
# Attempt to kill the hung process.
|
|
106
|
+
try:
|
|
107
|
+
process.kill() # type: ignore[possibly-undefined]
|
|
108
|
+
except ProcessLookupError:
|
|
109
|
+
pass
|
|
110
|
+
raise TimeoutError(f"Claude CLI did not finish within {timeout} seconds")
|
|
111
|
+
|
|
112
|
+
stdout = stdout_bytes.decode("utf-8", errors="replace")
|
|
113
|
+
stderr = stderr_bytes.decode("utf-8", errors="replace")
|
|
114
|
+
|
|
115
|
+
if process.returncode != 0:
|
|
116
|
+
logger.error(
|
|
117
|
+
"Claude CLI exited with code %d\nstderr: %s",
|
|
118
|
+
process.returncode,
|
|
119
|
+
stderr[:2000],
|
|
120
|
+
)
|
|
121
|
+
raise RuntimeError(
|
|
122
|
+
f"Claude CLI exited with code {process.returncode}: {stderr[:500]}"
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
if stderr:
|
|
126
|
+
logger.debug("Claude CLI stderr: %s", stderr[:1000])
|
|
127
|
+
|
|
128
|
+
# Parse JSON output.
|
|
129
|
+
output_text, new_session_id = _parse_output(stdout)
|
|
130
|
+
logger.info(
|
|
131
|
+
"Claude CLI finished: %d chars output, session=%s",
|
|
132
|
+
len(output_text),
|
|
133
|
+
new_session_id or "none",
|
|
134
|
+
)
|
|
135
|
+
return output_text, new_session_id
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _parse_output(raw: str) -> tuple[str, str | None]:
|
|
139
|
+
"""Parse the JSON output from ``claude --output-format json``.
|
|
140
|
+
|
|
141
|
+
The CLI emits a JSON object with at least a ``result`` field.
|
|
142
|
+
The ``session_id`` field may or may not be present.
|
|
143
|
+
"""
|
|
144
|
+
raw = raw.strip()
|
|
145
|
+
if not raw:
|
|
146
|
+
return "", None
|
|
147
|
+
|
|
148
|
+
try:
|
|
149
|
+
data = json.loads(raw)
|
|
150
|
+
except json.JSONDecodeError:
|
|
151
|
+
# If the output is not valid JSON, treat the whole thing as plain text.
|
|
152
|
+
logger.warning("Could not parse Claude CLI output as JSON; using raw text")
|
|
153
|
+
return raw, None
|
|
154
|
+
|
|
155
|
+
# The CLI may nest the text in different fields depending on version.
|
|
156
|
+
output_text: str = ""
|
|
157
|
+
if isinstance(data, dict):
|
|
158
|
+
output_text = (
|
|
159
|
+
data.get("result", "")
|
|
160
|
+
or data.get("text", "")
|
|
161
|
+
or data.get("content", "")
|
|
162
|
+
or ""
|
|
163
|
+
)
|
|
164
|
+
session_id = data.get("session_id")
|
|
165
|
+
else:
|
|
166
|
+
output_text = str(data)
|
|
167
|
+
session_id = None
|
|
168
|
+
|
|
169
|
+
return output_text, session_id
|