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.
- codeframe/__init__.py +11 -0
- codeframe/__main__.py +20 -0
- codeframe/adapters/__init__.py +5 -0
- codeframe/adapters/e2b/__init__.py +13 -0
- codeframe/adapters/e2b/adapter.py +342 -0
- codeframe/adapters/e2b/budget.py +71 -0
- codeframe/adapters/e2b/credential_scanner.py +134 -0
- codeframe/adapters/llm/__init__.py +92 -0
- codeframe/adapters/llm/anthropic.py +414 -0
- codeframe/adapters/llm/base.py +444 -0
- codeframe/adapters/llm/mock.py +281 -0
- codeframe/adapters/llm/openai.py +483 -0
- codeframe/agents/__init__.py +8 -0
- codeframe/agents/dependency_resolver.py +714 -0
- codeframe/auth/__init__.py +16 -0
- codeframe/auth/api_key_router.py +238 -0
- codeframe/auth/api_keys.py +156 -0
- codeframe/auth/dependencies.py +358 -0
- codeframe/auth/manager.py +178 -0
- codeframe/auth/models.py +30 -0
- codeframe/auth/router.py +93 -0
- codeframe/auth/schemas.py +15 -0
- codeframe/auth/scopes.py +53 -0
- codeframe/cli/__init__.py +12 -0
- codeframe/cli/__main__.py +20 -0
- codeframe/cli/api_client.py +275 -0
- codeframe/cli/app.py +5688 -0
- codeframe/cli/auth.py +122 -0
- codeframe/cli/auth_commands.py +958 -0
- codeframe/cli/commands/__init__.py +5 -0
- codeframe/cli/config_commands.py +79 -0
- codeframe/cli/dashboard_commands.py +67 -0
- codeframe/cli/engines_commands.py +205 -0
- codeframe/cli/env_commands.py +409 -0
- codeframe/cli/helpers.py +56 -0
- codeframe/cli/hooks_commands.py +208 -0
- codeframe/cli/import_commands.py +129 -0
- codeframe/cli/pr_commands.py +549 -0
- codeframe/cli/proof_commands.py +415 -0
- codeframe/cli/stats_commands.py +311 -0
- codeframe/cli/telemetry_runtime.py +153 -0
- codeframe/cli/validators.py +123 -0
- codeframe/config/rate_limits.py +165 -0
- codeframe/core/__init__.py +15 -0
- codeframe/core/adapters/__init__.py +43 -0
- codeframe/core/adapters/agent_adapter.py +114 -0
- codeframe/core/adapters/builtin.py +326 -0
- codeframe/core/adapters/claude_code.py +62 -0
- codeframe/core/adapters/codex.py +393 -0
- codeframe/core/adapters/git_utils.py +40 -0
- codeframe/core/adapters/kilocode.py +126 -0
- codeframe/core/adapters/opencode.py +48 -0
- codeframe/core/adapters/streaming_chat.py +483 -0
- codeframe/core/adapters/subprocess_adapter.py +213 -0
- codeframe/core/adapters/verification_wrapper.py +269 -0
- codeframe/core/agent.py +2183 -0
- codeframe/core/agents_config.py +569 -0
- codeframe/core/api_key_service.py +211 -0
- codeframe/core/artifacts.py +428 -0
- codeframe/core/blocker_detection.py +218 -0
- codeframe/core/blockers.py +433 -0
- codeframe/core/checkpoints.py +481 -0
- codeframe/core/conductor.py +2255 -0
- codeframe/core/config.py +827 -0
- codeframe/core/config_watcher.py +268 -0
- codeframe/core/context.py +542 -0
- codeframe/core/context_packager.py +234 -0
- codeframe/core/credentials.py +735 -0
- codeframe/core/dependency_analyzer.py +229 -0
- codeframe/core/dependency_graph.py +290 -0
- codeframe/core/diagnostic_agent.py +712 -0
- codeframe/core/diagnostics.py +616 -0
- codeframe/core/editor.py +556 -0
- codeframe/core/engine_registry.py +256 -0
- codeframe/core/engine_stats.py +231 -0
- codeframe/core/environment.py +697 -0
- codeframe/core/events.py +375 -0
- codeframe/core/executor.py +1005 -0
- codeframe/core/fix_tracker.py +480 -0
- codeframe/core/gates.py +1322 -0
- codeframe/core/git.py +477 -0
- codeframe/core/github_connect_service.py +178 -0
- codeframe/core/github_integration_config.py +118 -0
- codeframe/core/github_issues_service.py +449 -0
- codeframe/core/hooks.py +184 -0
- codeframe/core/importers/__init__.py +1 -0
- codeframe/core/importers/ralph.py +540 -0
- codeframe/core/installer.py +650 -0
- codeframe/core/models.py +1026 -0
- codeframe/core/notifications_config.py +183 -0
- codeframe/core/planner.py +437 -0
- codeframe/core/prd.py +670 -0
- codeframe/core/prd_discovery.py +1118 -0
- codeframe/core/prd_stress_test.py +499 -0
- codeframe/core/progress.py +126 -0
- codeframe/core/proof/__init__.py +34 -0
- codeframe/core/proof/capture.py +79 -0
- codeframe/core/proof/evidence.py +56 -0
- codeframe/core/proof/ledger.py +574 -0
- codeframe/core/proof/models.py +162 -0
- codeframe/core/proof/obligations.py +103 -0
- codeframe/core/proof/runner.py +233 -0
- codeframe/core/proof/scope.py +81 -0
- codeframe/core/proof/stubs.py +156 -0
- codeframe/core/quick_fixes.py +558 -0
- codeframe/core/react_agent.py +1650 -0
- codeframe/core/reconciliation.py +183 -0
- codeframe/core/replay.py +788 -0
- codeframe/core/review.py +285 -0
- codeframe/core/runtime.py +1134 -0
- codeframe/core/sandbox/__init__.py +27 -0
- codeframe/core/sandbox/context.py +98 -0
- codeframe/core/sandbox/worktree.py +20 -0
- codeframe/core/schedule.py +396 -0
- codeframe/core/stall_detector.py +71 -0
- codeframe/core/stall_monitor.py +134 -0
- codeframe/core/state_machine.py +121 -0
- codeframe/core/streaming.py +502 -0
- codeframe/core/task_tree.py +400 -0
- codeframe/core/tasks.py +1022 -0
- codeframe/core/telemetry.py +232 -0
- codeframe/core/templates.py +221 -0
- codeframe/core/tools.py +942 -0
- codeframe/core/workspace.py +887 -0
- codeframe/core/worktrees.py +276 -0
- codeframe/git/__init__.py +5 -0
- codeframe/git/github_integration.py +505 -0
- codeframe/lib/__init__.py +0 -0
- codeframe/lib/audit_logger.py +248 -0
- codeframe/lib/metrics_tracker.py +800 -0
- codeframe/lib/quality/__init__.py +7 -0
- codeframe/lib/quality/complexity_analyzer.py +316 -0
- codeframe/lib/quality/owasp_patterns.py +284 -0
- codeframe/lib/quality/security_scanner.py +250 -0
- codeframe/lib/rate_limiter.py +312 -0
- codeframe/notifications/__init__.py +0 -0
- codeframe/notifications/webhook.py +380 -0
- codeframe/planning/__init__.py +30 -0
- codeframe/planning/issue_generator.py +219 -0
- codeframe/planning/prd_template_functions.py +137 -0
- codeframe/planning/prd_templates.py +975 -0
- codeframe/planning/task_scheduler.py +511 -0
- codeframe/planning/task_templates.py +533 -0
- codeframe/platform_store/__init__.py +5 -0
- codeframe/platform_store/database.py +277 -0
- codeframe/platform_store/repositories/__init__.py +24 -0
- codeframe/platform_store/repositories/api_key_repository.py +245 -0
- codeframe/platform_store/repositories/audit_repository.py +67 -0
- codeframe/platform_store/repositories/base.py +295 -0
- codeframe/platform_store/repositories/interactive_sessions.py +165 -0
- codeframe/platform_store/repositories/token_repository.py +598 -0
- codeframe/platform_store/repositories/workspace_registry_repository.py +175 -0
- codeframe/platform_store/schema_manager.py +321 -0
- codeframe/templates/AGENTS.md.default +94 -0
- codeframe/tui/__init__.py +5 -0
- codeframe/tui/app.py +256 -0
- codeframe/tui/data_service.py +103 -0
- codeframe/ui/__init__.py +0 -0
- codeframe/ui/dependencies.py +103 -0
- codeframe/ui/models.py +999 -0
- codeframe/ui/response_models.py +201 -0
- codeframe/ui/routers/__init__.py +5 -0
- codeframe/ui/routers/_helpers.py +29 -0
- codeframe/ui/routers/batches_v2.py +315 -0
- codeframe/ui/routers/blockers_v2.py +320 -0
- codeframe/ui/routers/checkpoints_v2.py +310 -0
- codeframe/ui/routers/costs_v2.py +322 -0
- codeframe/ui/routers/diagnose_v2.py +225 -0
- codeframe/ui/routers/discovery_v2.py +417 -0
- codeframe/ui/routers/environment_v2.py +284 -0
- codeframe/ui/routers/events_v2.py +75 -0
- codeframe/ui/routers/gates_v2.py +166 -0
- codeframe/ui/routers/git_v2.py +284 -0
- codeframe/ui/routers/github_integrations_v2.py +532 -0
- codeframe/ui/routers/interactive_sessions_v2.py +238 -0
- codeframe/ui/routers/pr_v2.py +709 -0
- codeframe/ui/routers/prd_v2.py +695 -0
- codeframe/ui/routers/proof_v2.py +755 -0
- codeframe/ui/routers/review_v2.py +360 -0
- codeframe/ui/routers/schedule_v2.py +214 -0
- codeframe/ui/routers/session_chat_ws.py +354 -0
- codeframe/ui/routers/settings_v2.py +562 -0
- codeframe/ui/routers/streaming_v2.py +155 -0
- codeframe/ui/routers/tasks_v2.py +1098 -0
- codeframe/ui/routers/templates_v2.py +232 -0
- codeframe/ui/routers/terminal_ws.py +267 -0
- codeframe/ui/routers/workspace_v2.py +527 -0
- codeframe/ui/server.py +568 -0
- codeframe/ui/shared.py +241 -0
- codeframe/workspace/__init__.py +5 -0
- codeframe/workspace/manager.py +249 -0
- codeframe_ai-0.9.0.dist-info/METADATA +517 -0
- codeframe_ai-0.9.0.dist-info/RECORD +197 -0
- codeframe_ai-0.9.0.dist-info/WHEEL +5 -0
- codeframe_ai-0.9.0.dist-info/entry_points.txt +3 -0
- codeframe_ai-0.9.0.dist-info/licenses/LICENSE +661 -0
- 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)
|