Skip to content

Commit ed5d899

Browse files
authored
feat(api): DB schema + CRUD REST API for interactive agent sessions (#501)
## Summary - Adds `interactive_sessions` and `session_messages` tables to `SchemaManager` (idempotent DDL with CHECK constraints on state/role and composite indexes) - `InteractiveSessionRepository` with full CRUD: create, get, list, update_state, update_cost, end, add_message, get_messages - 6 REST endpoints at `/api/v2/sessions`: POST, GET, LIST, DELETE, POST messages, GET messages - State/role validation at both app and DB layers; rate limiting on all endpoints - `app.state.db` initialized in server lifespan for global DB access - `task_id`, `updated_at` included in `SessionResponse` - 20 tests in `tests/unit/test_interactive_sessions_api.py` using real in-memory SQLite ## Validation - Review feedback: All addressed (3 rounds — claude-review + coderabbit) - Demo: All 5 acceptance criteria verified via Showboat (API-only, no frontend) - Tests: 20/20 passing - CI: All checks green (Backend Unit Tests, Code Quality, Test Summary, claude-review, CodeRabbit, GitGuardian) - Linting: Clean Closes #501
1 parent b78408f commit ed5d899

6 files changed

Lines changed: 760 additions & 1 deletion

File tree

codeframe/persistence/database.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
PRRepository,
3939
APIKeyRepository,
4040
)
41+
from codeframe.persistence.repositories.interactive_sessions import InteractiveSessionRepository
4142

4243
if TYPE_CHECKING:
4344
pass
@@ -111,6 +112,7 @@ def __init__(self, db_path: Path | str):
111112
self.audit_logs: Optional[AuditRepository] = None
112113
self.pull_requests: Optional[PRRepository] = None
113114
self.api_keys: Optional[APIKeyRepository] = None
115+
self.interactive_sessions: Optional[InteractiveSessionRepository] = None
114116

115117
def initialize(self) -> None:
116118
"""Initialize database schema and repositories."""
@@ -158,6 +160,7 @@ def _initialize_repositories(self) -> None:
158160
self.audit_logs = AuditRepository(sync_conn=self.conn, async_conn=self._async_conn, database=self, sync_lock=self._sync_lock)
159161
self.pull_requests = PRRepository(sync_conn=self.conn, async_conn=self._async_conn, database=self, sync_lock=self._sync_lock)
160162
self.api_keys = APIKeyRepository(sync_conn=self.conn, async_conn=self._async_conn, database=self, sync_lock=self._sync_lock)
163+
self.interactive_sessions = InteractiveSessionRepository(sync_conn=self.conn, async_conn=self._async_conn, database=self, sync_lock=self._sync_lock)
161164

162165
# Backward compatibility properties (maintain old *_repository naming)
163166
@property
@@ -219,7 +222,7 @@ def _update_repository_async_connections(self) -> None:
219222
self.memories, self.context_items, self.checkpoints, self.git_branches,
220223
self.test_results, self.lint_results, self.code_reviews, self.quality_gates,
221224
self.token_usage, self.correction_attempts, self.activities, self.audit_logs,
222-
self.pull_requests]:
225+
self.pull_requests, self.interactive_sessions]:
223226
if repo:
224227
repo._async_conn = self._async_conn
225228

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
"""Repository for interactive agent session operations."""
2+
3+
from __future__ import annotations
4+
5+
import json
6+
import uuid
7+
from datetime import datetime, UTC
8+
from typing import Optional
9+
10+
from codeframe.persistence.repositories.base import BaseRepository
11+
12+
13+
class InteractiveSessionRepository(BaseRepository):
14+
"""Repository for interactive_sessions and session_messages tables."""
15+
16+
# -------------------------------------------------------------------------
17+
# Sessions
18+
# -------------------------------------------------------------------------
19+
20+
def create(
21+
self,
22+
workspace_path: str,
23+
task_id: Optional[str] = None,
24+
agent_type: str = "claude",
25+
model: Optional[str] = None,
26+
) -> dict:
27+
now = datetime.now(UTC).isoformat()
28+
session_id = str(uuid.uuid4())
29+
self._execute(
30+
"""
31+
INSERT INTO interactive_sessions
32+
(id, workspace_path, task_id, state, agent_type, model,
33+
cost_usd, input_tokens, output_tokens, created_at, updated_at, ended_at)
34+
VALUES (?, ?, ?, 'active', ?, ?, 0.0, 0, 0, ?, ?, NULL)
35+
""",
36+
(session_id, workspace_path, task_id, agent_type, model, now, now),
37+
)
38+
self._commit()
39+
return self.get(session_id)
40+
41+
def get(self, session_id: str) -> Optional[dict]:
42+
row = self._fetchone(
43+
"SELECT * FROM interactive_sessions WHERE id = ?", (session_id,)
44+
)
45+
return self._row_to_dict(row) if row else None
46+
47+
def list(
48+
self,
49+
workspace_path: Optional[str] = None,
50+
state: Optional[str] = None,
51+
limit: int = 50,
52+
) -> list[dict]:
53+
query = "SELECT * FROM interactive_sessions WHERE 1=1"
54+
params: list = []
55+
if workspace_path is not None:
56+
query += " AND workspace_path = ?"
57+
params.append(workspace_path)
58+
if state is not None:
59+
query += " AND state = ?"
60+
params.append(state)
61+
query += " ORDER BY created_at DESC LIMIT ?"
62+
params.append(limit)
63+
rows = self._fetchall(query, tuple(params))
64+
return [self._row_to_dict(r) for r in rows]
65+
66+
def update_state(self, session_id: str, state: str) -> None:
67+
"""Update session state. Called internally by the agent runtime, not via REST API.
68+
69+
Callers are responsible for validating state against VALID_STATES before calling.
70+
"""
71+
now = datetime.now(UTC).isoformat()
72+
self._execute(
73+
"UPDATE interactive_sessions SET state = ?, updated_at = ? WHERE id = ?",
74+
(state, now, session_id),
75+
)
76+
self._commit()
77+
78+
def update_cost(
79+
self, session_id: str, cost_usd: float, input_tokens: int, output_tokens: int
80+
) -> None:
81+
"""Accumulate cost and token counts. Called internally by the agent runtime, not via REST API.
82+
83+
The increment is applied atomically at the DB level to prevent lost-update races.
84+
"""
85+
now = datetime.now(UTC).isoformat()
86+
self._execute(
87+
"""
88+
UPDATE interactive_sessions
89+
SET cost_usd = cost_usd + ?, input_tokens = input_tokens + ?,
90+
output_tokens = output_tokens + ?, updated_at = ?
91+
WHERE id = ?
92+
""",
93+
(cost_usd, input_tokens, output_tokens, now, session_id),
94+
)
95+
self._commit()
96+
97+
def end(self, session_id: str) -> Optional[dict]:
98+
"""End a session. Returns the updated row, or None if session_id not found."""
99+
now = datetime.now(UTC).isoformat()
100+
cursor = self._execute(
101+
"""
102+
UPDATE interactive_sessions
103+
SET state = 'ended', ended_at = ?, updated_at = ?
104+
WHERE id = ?
105+
""",
106+
(now, now, session_id),
107+
)
108+
self._commit()
109+
if cursor.rowcount == 0:
110+
return None
111+
return self.get(session_id)
112+
113+
# -------------------------------------------------------------------------
114+
# Messages
115+
# -------------------------------------------------------------------------
116+
117+
def add_message(
118+
self,
119+
session_id: str,
120+
role: str,
121+
content: str,
122+
metadata: Optional[dict] = None,
123+
) -> dict:
124+
now = datetime.now(UTC).isoformat()
125+
message_id = str(uuid.uuid4())
126+
metadata_json = json.dumps(metadata) if metadata is not None else None
127+
self._execute(
128+
"""
129+
INSERT INTO session_messages (id, session_id, role, content, metadata, created_at)
130+
VALUES (?, ?, ?, ?, ?, ?)
131+
""",
132+
(message_id, session_id, role, content, metadata_json, now),
133+
)
134+
self._commit()
135+
return {
136+
"id": message_id,
137+
"session_id": session_id,
138+
"role": role,
139+
"content": content,
140+
"metadata": metadata,
141+
"created_at": now,
142+
}
143+
144+
def get_messages(
145+
self, session_id: str, limit: int = 100, offset: int = 0
146+
) -> list[dict]:
147+
rows = self._fetchall(
148+
"""
149+
SELECT * FROM session_messages
150+
WHERE session_id = ?
151+
ORDER BY created_at
152+
LIMIT ? OFFSET ?
153+
""",
154+
(session_id, limit, offset),
155+
)
156+
result = []
157+
for row in rows:
158+
d = self._row_to_dict(row)
159+
if d.get("metadata"):
160+
try:
161+
d["metadata"] = json.loads(d["metadata"])
162+
except (json.JSONDecodeError, TypeError):
163+
d["metadata"] = None
164+
result.append(d)
165+
return result

codeframe/persistence/schema_manager.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,9 @@ def create_schema(self) -> None:
6060
# Metrics and audit tables
6161
self._create_metrics_audit_tables(cursor)
6262

63+
# Interactive session tables
64+
self._create_interactive_session_tables(cursor)
65+
6366
# Apply schema migrations for existing databases BEFORE creating indexes
6467
# (indexes may reference columns added by migrations)
6568
self._apply_migrations(cursor)
@@ -832,6 +835,42 @@ def _create_metrics_audit_tables(self, cursor: sqlite3.Cursor) -> None:
832835
"""
833836
)
834837

838+
def _create_interactive_session_tables(self, cursor: sqlite3.Cursor) -> None:
839+
"""Create interactive_sessions and session_messages tables."""
840+
cursor.execute(
841+
"""
842+
CREATE TABLE IF NOT EXISTS interactive_sessions (
843+
id TEXT PRIMARY KEY,
844+
workspace_path TEXT NOT NULL,
845+
task_id TEXT,
846+
state TEXT NOT NULL DEFAULT 'active'
847+
CHECK (state IN ('active', 'paused', 'ended')),
848+
agent_type TEXT NOT NULL DEFAULT 'claude',
849+
model TEXT,
850+
cost_usd REAL DEFAULT 0.0,
851+
input_tokens INTEGER DEFAULT 0,
852+
output_tokens INTEGER DEFAULT 0,
853+
created_at TEXT NOT NULL,
854+
updated_at TEXT NOT NULL,
855+
ended_at TEXT
856+
)
857+
"""
858+
)
859+
860+
cursor.execute(
861+
"""
862+
CREATE TABLE IF NOT EXISTS session_messages (
863+
id TEXT PRIMARY KEY,
864+
session_id TEXT NOT NULL REFERENCES interactive_sessions(id) ON DELETE CASCADE,
865+
role TEXT NOT NULL
866+
CHECK (role IN ('user', 'assistant', 'tool_use', 'tool_result', 'thinking', 'system', 'error')),
867+
content TEXT NOT NULL,
868+
metadata TEXT,
869+
created_at TEXT NOT NULL
870+
)
871+
"""
872+
)
873+
835874
def _create_indexes(self, cursor: sqlite3.Cursor) -> None:
836875
"""Create all database indexes for performance."""
837876
# Issues indexes
@@ -951,6 +990,17 @@ def _create_indexes(self, cursor: sqlite3.Cursor) -> None:
951990
"CREATE INDEX IF NOT EXISTS idx_pull_requests_branch ON pull_requests(project_id, branch_name)"
952991
)
953992

993+
# Interactive session indexes
994+
cursor.execute(
995+
"CREATE INDEX IF NOT EXISTS idx_interactive_sessions_workspace ON interactive_sessions(workspace_path, state)"
996+
)
997+
cursor.execute(
998+
"CREATE INDEX IF NOT EXISTS idx_interactive_sessions_state ON interactive_sessions(state, created_at DESC)"
999+
)
1000+
cursor.execute(
1001+
"CREATE INDEX IF NOT EXISTS idx_session_messages_session ON session_messages(session_id, created_at)"
1002+
)
1003+
9541004
# Audit logs indexes
9551005
cursor.execute(
9561006
"CREATE INDEX IF NOT EXISTS idx_audit_logs_user_id ON audit_logs(user_id, timestamp DESC)"

0 commit comments

Comments
 (0)