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,502 @@
1
+ """Streaming infrastructure for real-time execution output.
2
+
3
+ This module provides:
4
+ 1. File-based streaming for `cf work follow`:
5
+ - RunOutputLogger: Writes agent output to a log file
6
+ - tail_run_output: Tails a log file for real-time streaming
7
+ - get_latest_lines: Reads buffered output (for --tail N)
8
+
9
+ 2. Event-based streaming for SSE/WebSocket:
10
+ - EventPublisher: Async event distribution with subscription support
11
+
12
+ Output files are stored at: .codeframe/runs/<run_id>/output.log
13
+
14
+ This module is headless - no FastAPI or HTTP dependencies.
15
+ """
16
+
17
+ import asyncio
18
+ import logging
19
+ import os
20
+ import threading
21
+ import time
22
+ from collections import defaultdict
23
+ from datetime import datetime, timezone
24
+ from pathlib import Path
25
+ from typing import (
26
+ AsyncIterator,
27
+ Dict,
28
+ Iterator,
29
+ List,
30
+ Optional,
31
+ TYPE_CHECKING,
32
+ )
33
+
34
+ from codeframe.core.workspace import Workspace
35
+
36
+ if TYPE_CHECKING:
37
+ from codeframe.core.models import ExecutionEvent
38
+
39
+ logger = logging.getLogger(__name__)
40
+
41
+ # Configuration via environment variables
42
+ SSE_TIMEOUT_SECONDS = int(os.getenv("SSE_TIMEOUT_SECONDS", "30"))
43
+ SSE_MAX_QUEUE_SIZE = int(os.getenv("SSE_MAX_QUEUE_SIZE", "1000"))
44
+ SSE_OUTPUT_MAX_CHARS = int(os.getenv("SSE_OUTPUT_MAX_CHARS", "2000"))
45
+
46
+
47
+ def get_run_output_path(workspace: Workspace, run_id: str) -> Path:
48
+ """Get the path for a run's output log file.
49
+
50
+ Args:
51
+ workspace: Target workspace
52
+ run_id: Run identifier
53
+
54
+ Returns:
55
+ Path to the output log file
56
+ """
57
+ return workspace.repo_path / ".codeframe" / "runs" / run_id / "output.log"
58
+
59
+
60
+ def run_output_exists(workspace: Workspace, run_id: str) -> bool:
61
+ """Check if a run's output log exists.
62
+
63
+ Args:
64
+ workspace: Target workspace
65
+ run_id: Run identifier
66
+
67
+ Returns:
68
+ True if the output file exists
69
+ """
70
+ return get_run_output_path(workspace, run_id).exists()
71
+
72
+
73
+ class RunOutputLogger:
74
+ """Logger that writes agent output to a file for streaming.
75
+
76
+ This class is used by the Agent to write verbose output to a log file
77
+ that can be tailed by `cf work follow`.
78
+
79
+ Usage:
80
+ with RunOutputLogger(workspace, run_id) as logger:
81
+ logger.write("Processing step 1...")
82
+ logger.write_timestamped("Step completed")
83
+
84
+ The log file is flushed after each write to enable real-time streaming.
85
+ """
86
+
87
+ def __init__(self, workspace: Workspace, run_id: str):
88
+ """Initialize the logger.
89
+
90
+ Args:
91
+ workspace: Target workspace
92
+ run_id: Run identifier
93
+ """
94
+ self.workspace = workspace
95
+ self.run_id = run_id
96
+ self.log_path = get_run_output_path(workspace, run_id)
97
+ self._file = None # Initialize before potential mkdir/open failure
98
+
99
+ # Ensure directory exists
100
+ self.log_path.parent.mkdir(parents=True, exist_ok=True)
101
+
102
+ # Open file in append mode
103
+ self._file = open(self.log_path, "a", encoding="utf-8")
104
+
105
+ def write(self, message: str) -> None:
106
+ """Write a message to the log file.
107
+
108
+ The file is flushed after each write to enable real-time streaming.
109
+
110
+ Args:
111
+ message: Message to write (should include newline if desired)
112
+ """
113
+ self._file.write(message)
114
+ self._file.flush()
115
+
116
+ def write_timestamped(self, message: str) -> None:
117
+ """Write a message with a timestamp prefix.
118
+
119
+ Format: [HH:MM:SS] message
120
+
121
+ Args:
122
+ message: Message to write
123
+ """
124
+ timestamp = datetime.now(timezone.utc).strftime("%H:%M:%S")
125
+ self.write(f"[{timestamp}] {message}\n")
126
+
127
+ def close(self) -> None:
128
+ """Close the log file."""
129
+ if hasattr(self, "_file") and self._file and not self._file.closed:
130
+ self._file.close()
131
+
132
+ def __enter__(self) -> "RunOutputLogger":
133
+ """Context manager entry."""
134
+ return self
135
+
136
+ def __exit__(self, exc_type, exc_val, exc_tb) -> None:
137
+ """Context manager exit - close the file."""
138
+ self.close()
139
+
140
+
141
+ def get_latest_lines(workspace: Workspace, run_id: str, count: int) -> list[str]:
142
+ """Get the last N lines from a run's output log.
143
+
144
+ Used by `cf work follow --tail N` to show buffered output.
145
+
146
+ Args:
147
+ workspace: Target workspace
148
+ run_id: Run identifier
149
+ count: Number of lines to return
150
+
151
+ Returns:
152
+ List of the last N lines (or fewer if file has less)
153
+ """
154
+ lines, _ = get_latest_lines_with_count(workspace, run_id, count)
155
+ return lines
156
+
157
+
158
+ def get_latest_lines_with_count(
159
+ workspace: Workspace, run_id: str, count: int
160
+ ) -> tuple[list[str], int]:
161
+ """Get the last N lines and total line count from a run's output log.
162
+
163
+ Args:
164
+ workspace: Target workspace
165
+ run_id: Run identifier
166
+ count: Number of lines to return
167
+
168
+ Returns:
169
+ Tuple of (last N lines, total line count)
170
+ """
171
+ log_path = get_run_output_path(workspace, run_id)
172
+
173
+ if not log_path.exists():
174
+ return [], 0
175
+
176
+ try:
177
+ with open(log_path, "r", encoding="utf-8") as f:
178
+ all_lines = f.readlines()
179
+
180
+ total = len(all_lines)
181
+
182
+ if count >= total:
183
+ return all_lines, total
184
+
185
+ return all_lines[-count:], total
186
+
187
+ except Exception:
188
+ return [], 0
189
+
190
+
191
+ def tail_run_output(
192
+ workspace: Workspace,
193
+ run_id: str,
194
+ since_line: int = 0,
195
+ poll_interval: float = 0.5,
196
+ max_iterations: Optional[int] = None,
197
+ max_wait: Optional[float] = None,
198
+ ) -> Iterator[str]:
199
+ """Tail a run's output log file, yielding new lines.
200
+
201
+ This generator polls the log file and yields new lines as they appear.
202
+ It's designed to be used with `cf work follow` for real-time streaming.
203
+
204
+ Args:
205
+ workspace: Target workspace
206
+ run_id: Run identifier
207
+ since_line: Start after this line number (0-based)
208
+ poll_interval: How often to check for new lines (seconds)
209
+ max_iterations: Stop after this many poll iterations (for testing)
210
+ max_wait: Maximum total wait time in seconds (for testing)
211
+
212
+ Yields:
213
+ Lines from the log file as they appear
214
+ """
215
+ log_path = get_run_output_path(workspace, run_id)
216
+ current_line = since_line
217
+ iterations = 0
218
+ start_time = time.time()
219
+
220
+ while True:
221
+ # Check termination conditions
222
+ if max_iterations is not None and iterations >= max_iterations:
223
+ break
224
+
225
+ if max_wait is not None and (time.time() - start_time) >= max_wait:
226
+ break
227
+
228
+ # Check if file exists
229
+ if not log_path.exists():
230
+ time.sleep(poll_interval)
231
+ iterations += 1
232
+ continue
233
+
234
+ try:
235
+ with open(log_path, "r", encoding="utf-8") as f:
236
+ all_lines = f.readlines()
237
+
238
+ # Yield new lines
239
+ while current_line < len(all_lines):
240
+ yield all_lines[current_line]
241
+ current_line += 1
242
+
243
+ except Exception:
244
+ pass # File might be temporarily unavailable
245
+
246
+ time.sleep(poll_interval)
247
+ iterations += 1
248
+
249
+
250
+ # =============================================================================
251
+ # Event-based streaming for SSE/WebSocket
252
+ # =============================================================================
253
+
254
+
255
+ class _Subscription:
256
+ """Internal subscription handle for tracking async iterators."""
257
+
258
+ # Sentinel value to signal end of stream
259
+ END_OF_STREAM = object()
260
+
261
+ def __init__(self, task_id: str, queue: asyncio.Queue, loop: asyncio.AbstractEventLoop):
262
+ self.task_id = task_id
263
+ self.queue = queue
264
+ self.loop = loop # Event loop for thread-safe operations
265
+ self.active = True
266
+
267
+
268
+ class EventPublisher:
269
+ """Async event publisher for real-time streaming.
270
+
271
+ Provides publish/subscribe functionality for ExecutionEvents,
272
+ used by SSE and WebSocket endpoints.
273
+
274
+ Features:
275
+ - Multiple subscribers per task
276
+ - Event isolation by task_id
277
+ - Graceful stream closure on task completion
278
+ - Thread-safe for concurrent access
279
+ - Sync publishing support for non-async code (e.g., agent)
280
+
281
+ Usage:
282
+ publisher = EventPublisher()
283
+
284
+ # Subscribe (in SSE/WebSocket handler)
285
+ async for event in publisher.subscribe(task_id):
286
+ yield f"data: {event.model_dump_json()}\\n\\n"
287
+
288
+ # Publish async (in async code)
289
+ await publisher.publish(task_id, ProgressEvent(...))
290
+
291
+ # Publish sync (in sync code like agent)
292
+ publisher.publish_sync(task_id, ProgressEvent(...))
293
+
294
+ # Signal completion (closes all subscribers)
295
+ await publisher.complete_task(task_id)
296
+
297
+ Configuration (via environment variables):
298
+ SSE_TIMEOUT_SECONDS: Timeout for waiting on events (default: 30)
299
+ SSE_MAX_QUEUE_SIZE: Max events per subscriber queue (default: 1000)
300
+ """
301
+
302
+ def __init__(self, timeout: Optional[float] = None, max_queue_size: Optional[int] = None):
303
+ """Initialize the event publisher.
304
+
305
+ Args:
306
+ timeout: Timeout for waiting on events (default: SSE_TIMEOUT_SECONDS env var)
307
+ max_queue_size: Max events per queue (default: SSE_MAX_QUEUE_SIZE env var)
308
+ """
309
+ # Map task_id -> list of subscriber queues
310
+ self._subscribers: Dict[str, List[_Subscription]] = defaultdict(list)
311
+ # Lock for thread-safe subscriber management
312
+ self._lock = asyncio.Lock()
313
+ # Thread lock for sync publishing
314
+ self._thread_lock = threading.Lock()
315
+ # Configuration
316
+ self._timeout = timeout if timeout is not None else SSE_TIMEOUT_SECONDS
317
+ self._max_queue_size = max_queue_size if max_queue_size is not None else SSE_MAX_QUEUE_SIZE
318
+
319
+ async def subscribe(self, task_id: str) -> AsyncIterator["ExecutionEvent"]:
320
+ """Subscribe to events for a task.
321
+
322
+ Creates an async iterator that yields events as they are published.
323
+ The iterator exits when the task completes (receives END_OF_STREAM sentinel).
324
+
325
+ Args:
326
+ task_id: Task ID to subscribe to
327
+
328
+ Yields:
329
+ ExecutionEvent objects as they are published
330
+ """
331
+ queue: asyncio.Queue = asyncio.Queue(maxsize=self._max_queue_size)
332
+ loop = asyncio.get_running_loop()
333
+ subscription = _Subscription(task_id, queue, loop)
334
+
335
+ async with self._lock:
336
+ self._subscribers[task_id].append(subscription)
337
+
338
+ try:
339
+ while True:
340
+ try:
341
+ # Wait for events with configurable timeout
342
+ item = await asyncio.wait_for(queue.get(), timeout=self._timeout)
343
+
344
+ # Check for end-of-stream sentinel
345
+ if item is _Subscription.END_OF_STREAM:
346
+ break
347
+
348
+ yield item
349
+ except asyncio.TimeoutError:
350
+ # Check if we should exit (task completed but no sentinel received)
351
+ if not subscription.active:
352
+ break
353
+ # Log if queue is getting full (backpressure warning)
354
+ if queue.qsize() > self._max_queue_size * 0.8:
355
+ logger.warning(
356
+ f"Event queue for task {task_id} is {queue.qsize()}/{self._max_queue_size} full"
357
+ )
358
+ # Otherwise continue waiting
359
+ continue
360
+ finally:
361
+ # Clean up subscription
362
+ async with self._lock:
363
+ if subscription in self._subscribers[task_id]:
364
+ self._subscribers[task_id].remove(subscription)
365
+ # Clean up empty task entries
366
+ if not self._subscribers[task_id]:
367
+ del self._subscribers[task_id]
368
+
369
+ async def publish(self, task_id: str, event: "ExecutionEvent") -> None:
370
+ """Publish an event to all subscribers of a task.
371
+
372
+ If there are no subscribers, the event is silently dropped.
373
+ If a subscriber's queue is full, the event is dropped for that subscriber
374
+ with a warning logged.
375
+
376
+ Args:
377
+ task_id: Task ID to publish to
378
+ event: Event to publish
379
+ """
380
+ async with self._lock:
381
+ subscribers = self._subscribers.get(task_id, [])
382
+ for subscription in subscribers:
383
+ if subscription.active:
384
+ try:
385
+ subscription.queue.put_nowait(event)
386
+ except asyncio.QueueFull:
387
+ logger.warning(
388
+ f"Event queue full for task {task_id}, dropping event: {event.event_type}"
389
+ )
390
+
391
+ def publish_sync(self, task_id: str, event: "ExecutionEvent") -> None:
392
+ """Publish an event synchronously (for use from non-async code).
393
+
394
+ This method is designed for use from the synchronous agent code.
395
+ Uses run_coroutine_threadsafe to delegate to the async publish() method,
396
+ ensuring single lock discipline (only _lock is used for subscriber access).
397
+
398
+ If there are no subscribers, the event is silently dropped.
399
+
400
+ Args:
401
+ task_id: Task ID to publish to
402
+ event: Event to publish
403
+ """
404
+ # Get any active subscription's event loop to run the coroutine
405
+ with self._thread_lock:
406
+ subscribers = self._subscribers.get(task_id, [])
407
+ if not subscribers:
408
+ return # No subscribers, nothing to do
409
+ # Use the first active subscriber's loop
410
+ loop = None
411
+ for subscription in subscribers:
412
+ if subscription.active:
413
+ loop = subscription.loop
414
+ break
415
+ if loop is None:
416
+ return # No active subscribers
417
+
418
+ # Delegate to async publish() using run_coroutine_threadsafe
419
+ # This ensures we use the same _lock for all subscriber access
420
+ try:
421
+ # Fire and forget - don't wait for the result to avoid blocking sync caller
422
+ asyncio.run_coroutine_threadsafe(self.publish(task_id, event), loop)
423
+ except RuntimeError:
424
+ # Event loop may be closed
425
+ logger.warning(f"Event loop closed for task {task_id}")
426
+
427
+ def complete_task_sync(self, task_id: str) -> None:
428
+ """Signal task completion synchronously (for use from non-async code).
429
+
430
+ Uses run_coroutine_threadsafe to delegate to the async complete_task() method,
431
+ ensuring single lock discipline (only _lock is used for subscriber access).
432
+
433
+ Args:
434
+ task_id: Task ID that completed
435
+ """
436
+ # Get any active subscription's event loop to run the coroutine
437
+ with self._thread_lock:
438
+ subscribers = self._subscribers.get(task_id, [])
439
+ if not subscribers:
440
+ return # No subscribers, nothing to do
441
+ # Use the first subscriber's loop (even if inactive, loop should still work)
442
+ loop = subscribers[0].loop
443
+
444
+ # Delegate to async complete_task() using run_coroutine_threadsafe
445
+ # This ensures we use the same _lock for all subscriber access
446
+ try:
447
+ # Fire and forget - don't wait for the result
448
+ asyncio.run_coroutine_threadsafe(self.complete_task(task_id), loop)
449
+ except RuntimeError:
450
+ # Event loop may be closed
451
+ logger.warning(f"Event loop closed for task {task_id}")
452
+
453
+ async def complete_task(self, task_id: str) -> None:
454
+ """Signal that a task is complete, closing all subscriber streams.
455
+
456
+ This should be called when task execution finishes (success or failure)
457
+ to allow SSE/WebSocket connections to close gracefully.
458
+
459
+ All queued events will be delivered before the stream closes.
460
+
461
+ Args:
462
+ task_id: Task ID that completed
463
+ """
464
+ async with self._lock:
465
+ subscribers = self._subscribers.get(task_id, [])
466
+ for subscription in subscribers:
467
+ subscription.active = False
468
+ # Send end-of-stream sentinel so subscribers exit gracefully
469
+ # after processing all queued events
470
+ await subscription.queue.put(_Subscription.END_OF_STREAM)
471
+
472
+ async def unsubscribe(
473
+ self, task_id: str, iterator: AsyncIterator["ExecutionEvent"]
474
+ ) -> None:
475
+ """Manually unsubscribe an iterator.
476
+
477
+ Typically not needed as subscriptions clean up automatically,
478
+ but can be used for explicit cleanup.
479
+
480
+ Args:
481
+ task_id: Task ID of the subscription
482
+ iterator: The async iterator returned by subscribe()
483
+ """
484
+ # The iterator cleanup happens in the finally block of subscribe()
485
+ # This method is provided for explicit control if needed
486
+ async with self._lock:
487
+ for subscription in self._subscribers.get(task_id, []):
488
+ subscription.active = False
489
+
490
+ def subscriber_count(self, task_id: str) -> int:
491
+ """Get the number of active subscribers for a task.
492
+
493
+ Args:
494
+ task_id: Task ID to check
495
+
496
+ Returns:
497
+ Number of active subscribers
498
+ """
499
+ # Note: This is a sync method for convenience, but accesses
500
+ # shared state. For production, consider making this async.
501
+ subscribers = self._subscribers.get(task_id, [])
502
+ return sum(1 for s in subscribers if s.active)