codeframe-ai 0.9.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 (197) hide show
  1. codeframe/__init__.py +11 -0
  2. codeframe/__main__.py +20 -0
  3. codeframe/adapters/__init__.py +5 -0
  4. codeframe/adapters/e2b/__init__.py +13 -0
  5. codeframe/adapters/e2b/adapter.py +342 -0
  6. codeframe/adapters/e2b/budget.py +71 -0
  7. codeframe/adapters/e2b/credential_scanner.py +134 -0
  8. codeframe/adapters/llm/__init__.py +92 -0
  9. codeframe/adapters/llm/anthropic.py +414 -0
  10. codeframe/adapters/llm/base.py +444 -0
  11. codeframe/adapters/llm/mock.py +281 -0
  12. codeframe/adapters/llm/openai.py +483 -0
  13. codeframe/agents/__init__.py +8 -0
  14. codeframe/agents/dependency_resolver.py +714 -0
  15. codeframe/auth/__init__.py +16 -0
  16. codeframe/auth/api_key_router.py +238 -0
  17. codeframe/auth/api_keys.py +156 -0
  18. codeframe/auth/dependencies.py +358 -0
  19. codeframe/auth/manager.py +178 -0
  20. codeframe/auth/models.py +30 -0
  21. codeframe/auth/router.py +93 -0
  22. codeframe/auth/schemas.py +15 -0
  23. codeframe/auth/scopes.py +53 -0
  24. codeframe/cli/__init__.py +12 -0
  25. codeframe/cli/__main__.py +20 -0
  26. codeframe/cli/api_client.py +275 -0
  27. codeframe/cli/app.py +5688 -0
  28. codeframe/cli/auth.py +122 -0
  29. codeframe/cli/auth_commands.py +958 -0
  30. codeframe/cli/commands/__init__.py +5 -0
  31. codeframe/cli/config_commands.py +79 -0
  32. codeframe/cli/dashboard_commands.py +67 -0
  33. codeframe/cli/engines_commands.py +205 -0
  34. codeframe/cli/env_commands.py +409 -0
  35. codeframe/cli/helpers.py +56 -0
  36. codeframe/cli/hooks_commands.py +208 -0
  37. codeframe/cli/import_commands.py +129 -0
  38. codeframe/cli/pr_commands.py +549 -0
  39. codeframe/cli/proof_commands.py +415 -0
  40. codeframe/cli/stats_commands.py +311 -0
  41. codeframe/cli/telemetry_runtime.py +153 -0
  42. codeframe/cli/validators.py +123 -0
  43. codeframe/config/rate_limits.py +165 -0
  44. codeframe/core/__init__.py +15 -0
  45. codeframe/core/adapters/__init__.py +43 -0
  46. codeframe/core/adapters/agent_adapter.py +114 -0
  47. codeframe/core/adapters/builtin.py +326 -0
  48. codeframe/core/adapters/claude_code.py +62 -0
  49. codeframe/core/adapters/codex.py +393 -0
  50. codeframe/core/adapters/git_utils.py +40 -0
  51. codeframe/core/adapters/kilocode.py +126 -0
  52. codeframe/core/adapters/opencode.py +48 -0
  53. codeframe/core/adapters/streaming_chat.py +483 -0
  54. codeframe/core/adapters/subprocess_adapter.py +213 -0
  55. codeframe/core/adapters/verification_wrapper.py +269 -0
  56. codeframe/core/agent.py +2183 -0
  57. codeframe/core/agents_config.py +569 -0
  58. codeframe/core/api_key_service.py +211 -0
  59. codeframe/core/artifacts.py +428 -0
  60. codeframe/core/blocker_detection.py +218 -0
  61. codeframe/core/blockers.py +433 -0
  62. codeframe/core/checkpoints.py +481 -0
  63. codeframe/core/conductor.py +2255 -0
  64. codeframe/core/config.py +827 -0
  65. codeframe/core/config_watcher.py +268 -0
  66. codeframe/core/context.py +542 -0
  67. codeframe/core/context_packager.py +234 -0
  68. codeframe/core/credentials.py +735 -0
  69. codeframe/core/dependency_analyzer.py +229 -0
  70. codeframe/core/dependency_graph.py +290 -0
  71. codeframe/core/diagnostic_agent.py +712 -0
  72. codeframe/core/diagnostics.py +616 -0
  73. codeframe/core/editor.py +556 -0
  74. codeframe/core/engine_registry.py +256 -0
  75. codeframe/core/engine_stats.py +231 -0
  76. codeframe/core/environment.py +697 -0
  77. codeframe/core/events.py +375 -0
  78. codeframe/core/executor.py +1005 -0
  79. codeframe/core/fix_tracker.py +480 -0
  80. codeframe/core/gates.py +1322 -0
  81. codeframe/core/git.py +477 -0
  82. codeframe/core/github_connect_service.py +178 -0
  83. codeframe/core/github_integration_config.py +118 -0
  84. codeframe/core/github_issues_service.py +449 -0
  85. codeframe/core/hooks.py +184 -0
  86. codeframe/core/importers/__init__.py +1 -0
  87. codeframe/core/importers/ralph.py +540 -0
  88. codeframe/core/installer.py +650 -0
  89. codeframe/core/models.py +1026 -0
  90. codeframe/core/notifications_config.py +183 -0
  91. codeframe/core/planner.py +437 -0
  92. codeframe/core/prd.py +670 -0
  93. codeframe/core/prd_discovery.py +1118 -0
  94. codeframe/core/prd_stress_test.py +499 -0
  95. codeframe/core/progress.py +126 -0
  96. codeframe/core/proof/__init__.py +34 -0
  97. codeframe/core/proof/capture.py +79 -0
  98. codeframe/core/proof/evidence.py +56 -0
  99. codeframe/core/proof/ledger.py +574 -0
  100. codeframe/core/proof/models.py +162 -0
  101. codeframe/core/proof/obligations.py +103 -0
  102. codeframe/core/proof/runner.py +233 -0
  103. codeframe/core/proof/scope.py +81 -0
  104. codeframe/core/proof/stubs.py +156 -0
  105. codeframe/core/quick_fixes.py +558 -0
  106. codeframe/core/react_agent.py +1650 -0
  107. codeframe/core/reconciliation.py +183 -0
  108. codeframe/core/replay.py +788 -0
  109. codeframe/core/review.py +285 -0
  110. codeframe/core/runtime.py +1134 -0
  111. codeframe/core/sandbox/__init__.py +27 -0
  112. codeframe/core/sandbox/context.py +98 -0
  113. codeframe/core/sandbox/worktree.py +20 -0
  114. codeframe/core/schedule.py +396 -0
  115. codeframe/core/stall_detector.py +71 -0
  116. codeframe/core/stall_monitor.py +134 -0
  117. codeframe/core/state_machine.py +121 -0
  118. codeframe/core/streaming.py +502 -0
  119. codeframe/core/task_tree.py +400 -0
  120. codeframe/core/tasks.py +1022 -0
  121. codeframe/core/telemetry.py +232 -0
  122. codeframe/core/templates.py +221 -0
  123. codeframe/core/tools.py +942 -0
  124. codeframe/core/workspace.py +887 -0
  125. codeframe/core/worktrees.py +276 -0
  126. codeframe/git/__init__.py +5 -0
  127. codeframe/git/github_integration.py +505 -0
  128. codeframe/lib/__init__.py +0 -0
  129. codeframe/lib/audit_logger.py +248 -0
  130. codeframe/lib/metrics_tracker.py +800 -0
  131. codeframe/lib/quality/__init__.py +7 -0
  132. codeframe/lib/quality/complexity_analyzer.py +316 -0
  133. codeframe/lib/quality/owasp_patterns.py +284 -0
  134. codeframe/lib/quality/security_scanner.py +250 -0
  135. codeframe/lib/rate_limiter.py +312 -0
  136. codeframe/notifications/__init__.py +0 -0
  137. codeframe/notifications/webhook.py +380 -0
  138. codeframe/planning/__init__.py +30 -0
  139. codeframe/planning/issue_generator.py +219 -0
  140. codeframe/planning/prd_template_functions.py +137 -0
  141. codeframe/planning/prd_templates.py +975 -0
  142. codeframe/planning/task_scheduler.py +511 -0
  143. codeframe/planning/task_templates.py +533 -0
  144. codeframe/platform_store/__init__.py +5 -0
  145. codeframe/platform_store/database.py +277 -0
  146. codeframe/platform_store/repositories/__init__.py +24 -0
  147. codeframe/platform_store/repositories/api_key_repository.py +245 -0
  148. codeframe/platform_store/repositories/audit_repository.py +67 -0
  149. codeframe/platform_store/repositories/base.py +295 -0
  150. codeframe/platform_store/repositories/interactive_sessions.py +165 -0
  151. codeframe/platform_store/repositories/token_repository.py +598 -0
  152. codeframe/platform_store/repositories/workspace_registry_repository.py +175 -0
  153. codeframe/platform_store/schema_manager.py +321 -0
  154. codeframe/templates/AGENTS.md.default +94 -0
  155. codeframe/tui/__init__.py +5 -0
  156. codeframe/tui/app.py +256 -0
  157. codeframe/tui/data_service.py +103 -0
  158. codeframe/ui/__init__.py +0 -0
  159. codeframe/ui/dependencies.py +103 -0
  160. codeframe/ui/models.py +999 -0
  161. codeframe/ui/response_models.py +201 -0
  162. codeframe/ui/routers/__init__.py +5 -0
  163. codeframe/ui/routers/_helpers.py +29 -0
  164. codeframe/ui/routers/batches_v2.py +315 -0
  165. codeframe/ui/routers/blockers_v2.py +320 -0
  166. codeframe/ui/routers/checkpoints_v2.py +310 -0
  167. codeframe/ui/routers/costs_v2.py +322 -0
  168. codeframe/ui/routers/diagnose_v2.py +225 -0
  169. codeframe/ui/routers/discovery_v2.py +417 -0
  170. codeframe/ui/routers/environment_v2.py +284 -0
  171. codeframe/ui/routers/events_v2.py +75 -0
  172. codeframe/ui/routers/gates_v2.py +166 -0
  173. codeframe/ui/routers/git_v2.py +284 -0
  174. codeframe/ui/routers/github_integrations_v2.py +532 -0
  175. codeframe/ui/routers/interactive_sessions_v2.py +238 -0
  176. codeframe/ui/routers/pr_v2.py +709 -0
  177. codeframe/ui/routers/prd_v2.py +695 -0
  178. codeframe/ui/routers/proof_v2.py +755 -0
  179. codeframe/ui/routers/review_v2.py +360 -0
  180. codeframe/ui/routers/schedule_v2.py +214 -0
  181. codeframe/ui/routers/session_chat_ws.py +354 -0
  182. codeframe/ui/routers/settings_v2.py +562 -0
  183. codeframe/ui/routers/streaming_v2.py +155 -0
  184. codeframe/ui/routers/tasks_v2.py +1098 -0
  185. codeframe/ui/routers/templates_v2.py +232 -0
  186. codeframe/ui/routers/terminal_ws.py +267 -0
  187. codeframe/ui/routers/workspace_v2.py +527 -0
  188. codeframe/ui/server.py +568 -0
  189. codeframe/ui/shared.py +241 -0
  190. codeframe/workspace/__init__.py +5 -0
  191. codeframe/workspace/manager.py +249 -0
  192. codeframe_ai-0.9.0.dist-info/METADATA +517 -0
  193. codeframe_ai-0.9.0.dist-info/RECORD +197 -0
  194. codeframe_ai-0.9.0.dist-info/WHEEL +5 -0
  195. codeframe_ai-0.9.0.dist-info/entry_points.txt +3 -0
  196. codeframe_ai-0.9.0.dist-info/licenses/LICENSE +661 -0
  197. codeframe_ai-0.9.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,295 @@
1
+ """Base repository class for database operations."""
2
+
3
+ import sqlite3
4
+ import threading
5
+ from datetime import datetime
6
+ from typing import Any, Dict, List, Optional, Union
7
+ import logging
8
+
9
+ import aiosqlite
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ class BaseRepository:
15
+ """Base class for all repositories.
16
+
17
+ Provides common database utilities and connection management.
18
+ Each repository handles a specific domain (projects, issues, tasks, etc.).
19
+
20
+ Supports both synchronous (sqlite3) and asynchronous (aiosqlite) operations.
21
+ Thread-safe synchronous operations are provided via a shared lock.
22
+ """
23
+
24
+ def __init__(
25
+ self,
26
+ sync_conn: Optional[sqlite3.Connection] = None,
27
+ async_conn: Optional[aiosqlite.Connection] = None,
28
+ database: Optional[Any] = None,
29
+ sync_lock: Optional[threading.Lock] = None
30
+ ):
31
+ """Initialize repository with database connections.
32
+
33
+ Args:
34
+ sync_conn: Synchronous sqlite3.Connection
35
+ async_conn: Asynchronous aiosqlite.Connection
36
+ database: Reference to parent Database instance (for cross-repository operations)
37
+ sync_lock: Threading lock for thread-safe synchronous operations
38
+
39
+ Note:
40
+ At least one connection must be provided. Both can be provided
41
+ to support repositories with both sync and async methods.
42
+ If sync_conn is provided without sync_lock, operations will not be thread-safe.
43
+ """
44
+ if sync_conn is None and async_conn is None:
45
+ raise ValueError("At least one connection (sync or async) must be provided")
46
+
47
+ self.conn = sync_conn # For backward compatibility
48
+ self._async_conn = async_conn
49
+ self._database = database # Reference to parent Database instance
50
+ self._sync_lock = sync_lock # Threading lock for thread-safe sync operations
51
+
52
+ def _execute(self, query: str, params: tuple = ()) -> sqlite3.Cursor:
53
+ """Execute a query synchronously with thread-safe locking.
54
+
55
+ Args:
56
+ query: SQL query string
57
+ params: Query parameters
58
+
59
+ Returns:
60
+ Cursor with query results
61
+
62
+ Raises:
63
+ RuntimeError: If sync connection is not available
64
+
65
+ Note:
66
+ Uses threading lock if available to ensure thread-safe access
67
+ to the shared connection object.
68
+ """
69
+ if self.conn is None:
70
+ raise RuntimeError("Sync connection not available, use async methods")
71
+
72
+ if self._sync_lock is not None:
73
+ with self._sync_lock:
74
+ return self.conn.execute(query, params)
75
+ else:
76
+ return self.conn.execute(query, params)
77
+
78
+ def _fetchone(self, query: str, params: tuple = ()) -> Optional[sqlite3.Row]:
79
+ """Fetch a single row synchronously.
80
+
81
+ Args:
82
+ query: SQL query string
83
+ params: Query parameters
84
+
85
+ Returns:
86
+ Row or None if no results
87
+ """
88
+ cursor = self._execute(query, params)
89
+ return cursor.fetchone()
90
+
91
+ def _fetchall(self, query: str, params: tuple = ()) -> List[sqlite3.Row]:
92
+ """Fetch all rows synchronously.
93
+
94
+ Args:
95
+ query: SQL query string
96
+ params: Query parameters
97
+
98
+ Returns:
99
+ List of rows
100
+ """
101
+ cursor = self._execute(query, params)
102
+ return cursor.fetchall()
103
+
104
+ def _commit(self) -> None:
105
+ """Commit the current transaction synchronously with thread-safe locking.
106
+
107
+ Raises:
108
+ RuntimeError: If sync connection is not available
109
+
110
+ Note:
111
+ Uses threading lock if available to ensure thread-safe access
112
+ to the shared connection object.
113
+ """
114
+ if self.conn is None:
115
+ raise RuntimeError("Sync connection not available, use async methods")
116
+
117
+ if self._sync_lock is not None:
118
+ with self._sync_lock:
119
+ self.conn.commit()
120
+ else:
121
+ self.conn.commit()
122
+
123
+ async def _execute_async(self, query: str, params: tuple = ()) -> aiosqlite.Cursor:
124
+ """Execute a query asynchronously.
125
+
126
+ Args:
127
+ query: SQL query string
128
+ params: Query parameters
129
+
130
+ Returns:
131
+ Cursor with query results
132
+
133
+ Raises:
134
+ RuntimeError: If async connection is not available
135
+ """
136
+ if self._async_conn is None:
137
+ raise RuntimeError("Async connection not available, use sync methods")
138
+ return await self._async_conn.execute(query, params)
139
+
140
+ async def _fetchone_async(self, query: str, params: tuple = ()) -> Optional[aiosqlite.Row]:
141
+ """Fetch a single row asynchronously.
142
+
143
+ Args:
144
+ query: SQL query string
145
+ params: Query parameters
146
+
147
+ Returns:
148
+ Row or None if no results
149
+ """
150
+ cursor = await self._execute_async(query, params)
151
+ return await cursor.fetchone()
152
+
153
+ async def _fetchall_async(self, query: str, params: tuple = ()) -> List[aiosqlite.Row]:
154
+ """Fetch all rows asynchronously.
155
+
156
+ Args:
157
+ query: SQL query string
158
+ params: Query parameters
159
+
160
+ Returns:
161
+ List of rows
162
+ """
163
+ cursor = await self._execute_async(query, params)
164
+ return await cursor.fetchall()
165
+
166
+ async def _commit_async(self) -> None:
167
+ """Commit the current transaction asynchronously.
168
+
169
+ Raises:
170
+ RuntimeError: If async connection is not available
171
+ """
172
+ if self._async_conn is None:
173
+ raise RuntimeError("Async connection not available, use sync methods")
174
+ await self._async_conn.commit()
175
+
176
+ def _row_to_dict(self, row: Union[sqlite3.Row, aiosqlite.Row]) -> Dict[str, Any]:
177
+ """Convert a database row to a dictionary.
178
+
179
+ Args:
180
+ row: SQLite Row object (sync or async)
181
+
182
+ Returns:
183
+ Dictionary with column names as keys
184
+
185
+ Note:
186
+ Both sqlite3.Row and aiosqlite.Row support dictionary-style access
187
+ and keys() method for column names.
188
+ """
189
+ if row is None:
190
+ return {}
191
+ return {key: row[key] for key in row.keys()}
192
+
193
+ def _parse_datetime(
194
+ self,
195
+ dt_str: Optional[str],
196
+ field_name: str = "",
197
+ row_id: Optional[int] = None
198
+ ) -> Optional[datetime]:
199
+ """Parse datetime string to datetime object.
200
+
201
+ Args:
202
+ dt_str: ISO format datetime string or None
203
+ field_name: Field name for logging (optional)
204
+ row_id: Row ID for logging (optional)
205
+
206
+ Returns:
207
+ datetime object or None if input is None
208
+
209
+ Raises:
210
+ ValueError: If datetime string is malformed
211
+ """
212
+ if dt_str is None:
213
+ return None
214
+
215
+ try:
216
+ # Parse ISO format: "2024-11-23T10:30:00" or "2024-11-23 10:30:00"
217
+ # Handle both 'T' and space separators
218
+ dt_str_normalized = dt_str.replace("T", " ")
219
+
220
+ # Try with microseconds first
221
+ try:
222
+ return datetime.fromisoformat(dt_str_normalized)
223
+ except ValueError:
224
+ # Try without microseconds
225
+ return datetime.strptime(dt_str_normalized, "%Y-%m-%d %H:%M:%S")
226
+ except (ValueError, AttributeError) as e:
227
+ context = f" for {field_name}" if field_name else ""
228
+ row_context = f" (row {row_id})" if row_id else ""
229
+ logger.warning(
230
+ f"Failed to parse datetime '{dt_str}'{context}{row_context}: {e}"
231
+ )
232
+ raise ValueError(f"Invalid datetime format: {dt_str}") from e
233
+
234
+ def _format_datetime(self, dt: Optional[datetime]) -> Optional[str]:
235
+ """Format datetime object to ISO string for database storage.
236
+
237
+ Args:
238
+ dt: datetime object or None
239
+
240
+ Returns:
241
+ ISO format datetime string or None
242
+ """
243
+ if dt is None:
244
+ return None
245
+ return dt.isoformat()
246
+
247
+ async def _get_async_conn(self) -> aiosqlite.Connection:
248
+ """Get async connection with health check and automatic reconnection.
249
+
250
+ This method delegates to the parent Database instance which:
251
+ 1. Creates connection if none exists (lazy initialization)
252
+ 2. Checks connection health via simple query
253
+ 3. Reconnects automatically if connection is dead
254
+ 4. Uses a lock to prevent race conditions
255
+
256
+ Returns:
257
+ Active aiosqlite connection
258
+
259
+ Raises:
260
+ RuntimeError: If database reference is not available
261
+ aiosqlite.Error: If connection cannot be established
262
+
263
+ Note:
264
+ Uses async connection with automatic health check and reconnection.
265
+ Call close_async() when done to release database resources.
266
+ """
267
+ if self._database is None:
268
+ raise RuntimeError("Database reference not available for async connection")
269
+ return await self._database._get_async_conn()
270
+
271
+ def _ensure_rfc3339(self, timestamp_str: Optional[str]) -> Optional[str]:
272
+ """Ensure timestamp is in RFC 3339 format with timezone.
273
+
274
+ Args:
275
+ timestamp_str: Timestamp string (may be SQLite format or RFC 3339)
276
+
277
+ Returns:
278
+ RFC 3339 formatted timestamp (with 'Z' suffix for UTC) or None
279
+
280
+ Note:
281
+ Converts SQLite timestamps like "2025-10-17 22:01:56" to "2025-10-17T22:01:56Z".
282
+ Timestamps already in RFC 3339 format are returned as-is.
283
+ """
284
+ if not timestamp_str:
285
+ return timestamp_str
286
+ # If already has 'Z' or timezone, return as-is
287
+ if "Z" in timestamp_str or "+" in timestamp_str:
288
+ return timestamp_str
289
+ # Parse and add Z suffix for UTC
290
+ try:
291
+ # SQLite format: "2025-10-17 22:01:56"
292
+ dt = datetime.fromisoformat(timestamp_str)
293
+ return dt.isoformat() + "Z"
294
+ except ValueError:
295
+ return timestamp_str
@@ -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.platform_store.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