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,354 @@
|
|
|
1
|
+
"""WebSocket router for per-session streaming agent chat.
|
|
2
|
+
|
|
3
|
+
Endpoint:
|
|
4
|
+
WS /ws/sessions/{session_id}/chat?token=<JWT>
|
|
5
|
+
|
|
6
|
+
Client → Server message types:
|
|
7
|
+
{"type": "message", "content": "..."}
|
|
8
|
+
{"type": "interrupt"}
|
|
9
|
+
{"type": "ping"}
|
|
10
|
+
|
|
11
|
+
Server → Client message types:
|
|
12
|
+
{"type": "text_delta", "content": "..."}
|
|
13
|
+
{"type": "tool_use_start", "tool_name": "...", "tool_input": {...}}
|
|
14
|
+
{"type": "tool_result", "tool_name": "...", "content": "..."}
|
|
15
|
+
{"type": "thinking", "content": "..."}
|
|
16
|
+
{"type": "cost_update", "cost_usd": 0.003, "input_tokens": 100, "output_tokens": 50}
|
|
17
|
+
{"type": "done"}
|
|
18
|
+
{"type": "error", "message": "..."}
|
|
19
|
+
{"type": "pong"}
|
|
20
|
+
|
|
21
|
+
Architecture note: This router intentionally handles session orchestration
|
|
22
|
+
(activation, interrupt coordination, cost aggregation) rather than delegating
|
|
23
|
+
to a core service. A future refactoring (TODO: extract to
|
|
24
|
+
core.session_chat_service) would make this a thin transport adapter. See
|
|
25
|
+
GitHub issue #502 for context.
|
|
26
|
+
|
|
27
|
+
Workspace scoping: This endpoint is intentionally exempt from workspace_path
|
|
28
|
+
query param validation. Auth is already scoped to a user via JWT and the
|
|
29
|
+
session_id identifies the resource — workspace scoping on a per-session WS
|
|
30
|
+
would be redundant. Revisit if multi-tenant workspace isolation is required.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
import asyncio
|
|
34
|
+
import json
|
|
35
|
+
import logging
|
|
36
|
+
from pathlib import Path
|
|
37
|
+
from typing import Optional
|
|
38
|
+
|
|
39
|
+
import jwt as pyjwt
|
|
40
|
+
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
|
41
|
+
from sqlalchemy import select
|
|
42
|
+
|
|
43
|
+
from codeframe.auth.manager import SECRET, JWT_ALGORITHM, JWT_AUDIENCE, get_async_session_maker
|
|
44
|
+
from codeframe.auth.models import User
|
|
45
|
+
from codeframe.core.adapters.streaming_chat import StreamingChatAdapter
|
|
46
|
+
from codeframe.ui.shared import session_chat_manager
|
|
47
|
+
|
|
48
|
+
logger = logging.getLogger(__name__)
|
|
49
|
+
|
|
50
|
+
router = APIRouter(tags=["websocket"])
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# ---------------------------------------------------------------------------
|
|
54
|
+
# Helpers
|
|
55
|
+
# ---------------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
async def _authenticate_websocket(websocket: WebSocket) -> Optional[int]:
|
|
59
|
+
"""Validate JWT from query param. Returns user_id or closes with 1008."""
|
|
60
|
+
token = websocket.query_params.get("token")
|
|
61
|
+
if not token:
|
|
62
|
+
await websocket.close(code=1008, reason="Authentication required: missing token")
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
try:
|
|
66
|
+
payload = pyjwt.decode(token, SECRET, algorithms=[JWT_ALGORITHM], audience=JWT_AUDIENCE)
|
|
67
|
+
user_id_str = payload.get("sub")
|
|
68
|
+
if not user_id_str:
|
|
69
|
+
await websocket.close(code=1008, reason="Invalid token: missing subject")
|
|
70
|
+
return None
|
|
71
|
+
user_id = int(user_id_str)
|
|
72
|
+
except pyjwt.ExpiredSignatureError:
|
|
73
|
+
await websocket.close(code=1008, reason="Token expired")
|
|
74
|
+
return None
|
|
75
|
+
except (pyjwt.InvalidTokenError, ValueError) as exc:
|
|
76
|
+
logger.debug("WebSocket JWT decode error: %s", exc)
|
|
77
|
+
await websocket.close(code=1008, reason="Invalid authentication token")
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
try:
|
|
81
|
+
async_session_maker = get_async_session_maker()
|
|
82
|
+
async with async_session_maker() as session:
|
|
83
|
+
result = await session.execute(select(User).where(User.id == user_id))
|
|
84
|
+
user = result.scalar_one_or_none()
|
|
85
|
+
if user is None:
|
|
86
|
+
await websocket.close(code=1008, reason="User not found")
|
|
87
|
+
return None
|
|
88
|
+
if not user.is_active:
|
|
89
|
+
await websocket.close(code=1008, reason="User is inactive")
|
|
90
|
+
return None
|
|
91
|
+
except Exception as exc:
|
|
92
|
+
logger.error("WebSocket user lookup error: %s", exc)
|
|
93
|
+
await websocket.close(code=1008, reason="Authentication failed")
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
return user_id
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
async def _run_streaming_adapter(
|
|
100
|
+
session_id: str,
|
|
101
|
+
user_message: str,
|
|
102
|
+
token_queue: asyncio.Queue,
|
|
103
|
+
interrupt_event: asyncio.Event,
|
|
104
|
+
db_repo,
|
|
105
|
+
workspace_path: Path,
|
|
106
|
+
) -> None:
|
|
107
|
+
"""Drive the StreamingChatAdapter and forward ChatEvents into the token queue.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
session_id: ID of the interactive session.
|
|
111
|
+
user_message: The user's message content.
|
|
112
|
+
token_queue: Queue consumed by the relay task for WebSocket forwarding.
|
|
113
|
+
interrupt_event: Signals the adapter to stop mid-stream.
|
|
114
|
+
db_repo: ``InteractiveSessionRepository`` for history load/persist.
|
|
115
|
+
workspace_path: Absolute path to the workspace for file-system tools.
|
|
116
|
+
"""
|
|
117
|
+
try:
|
|
118
|
+
from codeframe.adapters.llm.anthropic import AnthropicProvider
|
|
119
|
+
provider = AnthropicProvider()
|
|
120
|
+
adapter = StreamingChatAdapter(
|
|
121
|
+
session_id=session_id,
|
|
122
|
+
db_repo=db_repo,
|
|
123
|
+
workspace_path=workspace_path,
|
|
124
|
+
provider=provider,
|
|
125
|
+
)
|
|
126
|
+
async for event in adapter.send_message(
|
|
127
|
+
content=user_message,
|
|
128
|
+
history=[],
|
|
129
|
+
interrupt_event=interrupt_event,
|
|
130
|
+
):
|
|
131
|
+
await token_queue.put(event.to_dict())
|
|
132
|
+
except Exception as exc:
|
|
133
|
+
logger.error("_run_streaming_adapter error: %s", exc, exc_info=True)
|
|
134
|
+
await token_queue.put({"type": "error", "message": str(exc)})
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# ---------------------------------------------------------------------------
|
|
138
|
+
# Endpoint
|
|
139
|
+
# ---------------------------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@router.websocket("/ws/sessions/{session_id}/chat")
|
|
143
|
+
async def session_chat_ws(session_id: str, websocket: WebSocket) -> None:
|
|
144
|
+
"""Bidirectional WebSocket for streaming agent chat on an interactive session."""
|
|
145
|
+
# --- Auth ---
|
|
146
|
+
user_id = await _authenticate_websocket(websocket)
|
|
147
|
+
if user_id is None:
|
|
148
|
+
return
|
|
149
|
+
|
|
150
|
+
# --- Session validation ---
|
|
151
|
+
db = getattr(websocket.app.state, "db", None)
|
|
152
|
+
if db is None:
|
|
153
|
+
await websocket.close(code=1011, reason="Database unavailable")
|
|
154
|
+
return
|
|
155
|
+
|
|
156
|
+
session = await asyncio.to_thread(db.interactive_sessions.get, session_id)
|
|
157
|
+
if session is None or session.get("state") == "ended":
|
|
158
|
+
await websocket.close(code=4008, reason="Session not found or ended")
|
|
159
|
+
return
|
|
160
|
+
|
|
161
|
+
# --- Accept connection; everything after this point must run inside the
|
|
162
|
+
# try/finally so unregister() and close() always execute even if
|
|
163
|
+
# update_state, register, or get_token_queue raises. ---
|
|
164
|
+
await websocket.accept()
|
|
165
|
+
|
|
166
|
+
relay: Optional[asyncio.Task] = None
|
|
167
|
+
adapter_task: list[Optional[asyncio.Task]] = [None]
|
|
168
|
+
|
|
169
|
+
try:
|
|
170
|
+
await asyncio.to_thread(db.interactive_sessions.update_state, session_id, "active")
|
|
171
|
+
await session_chat_manager.register(session_id, websocket)
|
|
172
|
+
|
|
173
|
+
token_queue = await session_chat_manager.get_token_queue(session_id)
|
|
174
|
+
|
|
175
|
+
# ---- Relay task: token_queue → WebSocket ----------------------------
|
|
176
|
+
async def _relay() -> None:
|
|
177
|
+
"""Forward adapter events to the client; persist cost on 'done'."""
|
|
178
|
+
turn_cost = {"cost_usd": 0.0, "input_tokens": 0, "output_tokens": 0}
|
|
179
|
+
try:
|
|
180
|
+
while True:
|
|
181
|
+
event = await token_queue.get()
|
|
182
|
+
event_type = event.get("type")
|
|
183
|
+
|
|
184
|
+
if event_type == "cost_update":
|
|
185
|
+
turn_cost["cost_usd"] += event.get("cost_usd", 0.0)
|
|
186
|
+
turn_cost["input_tokens"] += event.get("input_tokens", 0)
|
|
187
|
+
turn_cost["output_tokens"] += event.get("output_tokens", 0)
|
|
188
|
+
try:
|
|
189
|
+
await websocket.send_json(event)
|
|
190
|
+
except Exception as exc:
|
|
191
|
+
logger.warning(
|
|
192
|
+
"session_id=%s send_json(cost_update) failed: %s", session_id, exc
|
|
193
|
+
)
|
|
194
|
+
return
|
|
195
|
+
|
|
196
|
+
elif event_type == "done":
|
|
197
|
+
# Persist cost BEFORE sending "done" so clients that
|
|
198
|
+
# immediately fetch session stats observe accurate totals.
|
|
199
|
+
if (
|
|
200
|
+
turn_cost["cost_usd"]
|
|
201
|
+
or turn_cost["input_tokens"]
|
|
202
|
+
or turn_cost["output_tokens"]
|
|
203
|
+
):
|
|
204
|
+
try:
|
|
205
|
+
await asyncio.to_thread(
|
|
206
|
+
db.interactive_sessions.update_cost,
|
|
207
|
+
session_id,
|
|
208
|
+
turn_cost["cost_usd"],
|
|
209
|
+
turn_cost["input_tokens"],
|
|
210
|
+
turn_cost["output_tokens"],
|
|
211
|
+
)
|
|
212
|
+
except Exception as exc:
|
|
213
|
+
logger.error(
|
|
214
|
+
"session_id=%s update_cost failed: %s turn_cost=%s",
|
|
215
|
+
session_id,
|
|
216
|
+
exc,
|
|
217
|
+
turn_cost,
|
|
218
|
+
)
|
|
219
|
+
turn_cost = {"cost_usd": 0.0, "input_tokens": 0, "output_tokens": 0}
|
|
220
|
+
try:
|
|
221
|
+
await websocket.send_json(event)
|
|
222
|
+
except Exception as exc:
|
|
223
|
+
logger.warning(
|
|
224
|
+
"session_id=%s send_json(done) failed: %s", session_id, exc
|
|
225
|
+
)
|
|
226
|
+
return
|
|
227
|
+
|
|
228
|
+
else:
|
|
229
|
+
try:
|
|
230
|
+
await websocket.send_json(event)
|
|
231
|
+
except Exception as exc:
|
|
232
|
+
logger.warning(
|
|
233
|
+
"session_id=%s send_json(%s) failed: %s",
|
|
234
|
+
session_id,
|
|
235
|
+
event_type,
|
|
236
|
+
exc,
|
|
237
|
+
)
|
|
238
|
+
return
|
|
239
|
+
finally:
|
|
240
|
+
# Flush any cost accumulated during a cancelled/aborted turn
|
|
241
|
+
if (
|
|
242
|
+
turn_cost["cost_usd"]
|
|
243
|
+
or turn_cost["input_tokens"]
|
|
244
|
+
or turn_cost["output_tokens"]
|
|
245
|
+
):
|
|
246
|
+
try:
|
|
247
|
+
await asyncio.to_thread(
|
|
248
|
+
db.interactive_sessions.update_cost,
|
|
249
|
+
session_id,
|
|
250
|
+
turn_cost["cost_usd"],
|
|
251
|
+
turn_cost["input_tokens"],
|
|
252
|
+
turn_cost["output_tokens"],
|
|
253
|
+
)
|
|
254
|
+
except Exception as exc:
|
|
255
|
+
logger.error(
|
|
256
|
+
"session_id=%s relay finally update_cost failed: %s", session_id, exc
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
# ---- Receive task: WebSocket → dispatch -----------------------------
|
|
260
|
+
async def _receive() -> None:
|
|
261
|
+
"""Read client messages and dispatch to ping/interrupt/message handlers."""
|
|
262
|
+
while True:
|
|
263
|
+
try:
|
|
264
|
+
raw = await websocket.receive_text()
|
|
265
|
+
except WebSocketDisconnect:
|
|
266
|
+
raise
|
|
267
|
+
|
|
268
|
+
try:
|
|
269
|
+
msg = json.loads(raw)
|
|
270
|
+
except json.JSONDecodeError:
|
|
271
|
+
try:
|
|
272
|
+
await websocket.send_json({"type": "error", "message": "Invalid JSON"})
|
|
273
|
+
except Exception:
|
|
274
|
+
pass
|
|
275
|
+
continue
|
|
276
|
+
|
|
277
|
+
msg_type = msg.get("type")
|
|
278
|
+
|
|
279
|
+
if msg_type == "ping":
|
|
280
|
+
await websocket.send_json({"type": "pong"})
|
|
281
|
+
|
|
282
|
+
elif msg_type == "interrupt":
|
|
283
|
+
await session_chat_manager.signal_interrupt(session_id)
|
|
284
|
+
|
|
285
|
+
elif msg_type == "message":
|
|
286
|
+
content = msg.get("content", "")
|
|
287
|
+
|
|
288
|
+
# Cancel any in-flight adapter
|
|
289
|
+
if adapter_task[0] and not adapter_task[0].done():
|
|
290
|
+
adapter_task[0].cancel()
|
|
291
|
+
try:
|
|
292
|
+
await adapter_task[0]
|
|
293
|
+
except (asyncio.CancelledError, Exception) as exc:
|
|
294
|
+
logger.debug(
|
|
295
|
+
"session_id=%s adapter cancelled: %s", session_id, exc
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
# Reset interrupt and drain stale queue items
|
|
299
|
+
await session_chat_manager.reset_interrupt(session_id)
|
|
300
|
+
while not token_queue.empty():
|
|
301
|
+
try:
|
|
302
|
+
token_queue.get_nowait()
|
|
303
|
+
except asyncio.QueueEmpty:
|
|
304
|
+
break
|
|
305
|
+
|
|
306
|
+
interrupt_event = await session_chat_manager.get_interrupt_event(session_id)
|
|
307
|
+
raw_workspace = session.get("workspace_path")
|
|
308
|
+
if not raw_workspace:
|
|
309
|
+
logger.warning(
|
|
310
|
+
"session_id=%s has no workspace_path set; "
|
|
311
|
+
"file tools will scope to server CWD",
|
|
312
|
+
session_id,
|
|
313
|
+
)
|
|
314
|
+
workspace_path = Path(raw_workspace or ".")
|
|
315
|
+
adapter_task[0] = asyncio.create_task(
|
|
316
|
+
_run_streaming_adapter(
|
|
317
|
+
session_id,
|
|
318
|
+
content,
|
|
319
|
+
token_queue,
|
|
320
|
+
interrupt_event,
|
|
321
|
+
db.interactive_sessions,
|
|
322
|
+
workspace_path,
|
|
323
|
+
)
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
relay = asyncio.create_task(_relay())
|
|
327
|
+
await _receive()
|
|
328
|
+
|
|
329
|
+
except WebSocketDisconnect:
|
|
330
|
+
logger.debug("Session chat WebSocket disconnected: session_id=%s", session_id)
|
|
331
|
+
except Exception as exc:
|
|
332
|
+
logger.error("Session chat WebSocket error: %s", exc, exc_info=True)
|
|
333
|
+
try:
|
|
334
|
+
await websocket.send_json({"type": "error", "message": str(exc)})
|
|
335
|
+
except Exception:
|
|
336
|
+
pass
|
|
337
|
+
finally:
|
|
338
|
+
if relay is not None:
|
|
339
|
+
relay.cancel()
|
|
340
|
+
try:
|
|
341
|
+
await relay
|
|
342
|
+
except (asyncio.CancelledError, Exception):
|
|
343
|
+
pass
|
|
344
|
+
if adapter_task[0] and not adapter_task[0].done():
|
|
345
|
+
adapter_task[0].cancel()
|
|
346
|
+
try:
|
|
347
|
+
await adapter_task[0]
|
|
348
|
+
except (asyncio.CancelledError, Exception) as exc:
|
|
349
|
+
logger.debug("session_id=%s adapter cleanup: %s", session_id, exc)
|
|
350
|
+
await session_chat_manager.unregister(session_id, websocket)
|
|
351
|
+
try:
|
|
352
|
+
await websocket.close()
|
|
353
|
+
except Exception:
|
|
354
|
+
pass
|