deeptutor-server 1.7.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 (48) hide show
  1. deeptutor/api/__init__.py +0 -0
  2. deeptutor/api/contracts/__init__.py +21 -0
  3. deeptutor/api/contracts/export.py +110 -0
  4. deeptutor/api/contracts/turn_protocol.py +313 -0
  5. deeptutor/api/main.py +547 -0
  6. deeptutor/api/module_routes.py +42 -0
  7. deeptutor/api/router_index.json +5752 -0
  8. deeptutor/api/router_loader.py +139 -0
  9. deeptutor/api/routers/__init__.py +1 -0
  10. deeptutor/api/routers/agent_config.py +59 -0
  11. deeptutor/api/routers/attachments.py +79 -0
  12. deeptutor/api/routers/auth.py +1937 -0
  13. deeptutor/api/routers/capabilities.py +44 -0
  14. deeptutor/api/routers/capabilities_settings.py +38 -0
  15. deeptutor/api/routers/dashboard.py +292 -0
  16. deeptutor/api/routers/file_library.py +169 -0
  17. deeptutor/api/routers/file_preview.py +205 -0
  18. deeptutor/api/routers/imports.py +146 -0
  19. deeptutor/api/routers/learning_journal.py +18 -0
  20. deeptutor/api/routers/mcp_settings.py +133 -0
  21. deeptutor/api/routers/modules.py +12 -0
  22. deeptutor/api/routers/multi_user.py +943 -0
  23. deeptutor/api/routers/outputs.py +97 -0
  24. deeptutor/api/routers/personas.py +154 -0
  25. deeptutor/api/routers/plugins.py +416 -0
  26. deeptutor/api/routers/quiz_judge.py +464 -0
  27. deeptutor/api/routers/sessions.py +801 -0
  28. deeptutor/api/routers/settings.py +2503 -0
  29. deeptutor/api/routers/skills.py +315 -0
  30. deeptutor/api/routers/space_cli_apps.py +222 -0
  31. deeptutor/api/routers/space_mcp.py +460 -0
  32. deeptutor/api/routers/subagents.py +306 -0
  33. deeptutor/api/routers/system.py +633 -0
  34. deeptutor/api/routers/task_board.py +139 -0
  35. deeptutor/api/routers/tools.py +268 -0
  36. deeptutor/api/routers/unified_ws.py +385 -0
  37. deeptutor/api/routers/workspace.py +371 -0
  38. deeptutor/api/run_server.py +99 -0
  39. deeptutor/api/security_guard.py +91 -0
  40. deeptutor/api/utils/http_headers.py +27 -0
  41. deeptutor/api/utils/progress_broadcaster.py +73 -0
  42. deeptutor/api/utils/task_id_manager.py +120 -0
  43. deeptutor/api/utils/task_log_stream.py +362 -0
  44. deeptutor/api/utils/tool_options.py +133 -0
  45. deeptutor_server-1.7.0.dist-info/METADATA +14 -0
  46. deeptutor_server-1.7.0.dist-info/RECORD +48 -0
  47. deeptutor_server-1.7.0.dist-info/WHEEL +5 -0
  48. deeptutor_server-1.7.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,385 @@
1
+ """Unified WebSocket adapter for turn execution and replayable streaming.
2
+
3
+ All mutating operations are commands on :class:`TurnApplicationService`.
4
+ Subscriptions and active-turn checks are deliberately read-only, including
5
+ when the owner worker has disappeared and leader recovery is still pending.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import json
12
+ import logging
13
+ from typing import Any
14
+
15
+ from fastapi import APIRouter, WebSocket, WebSocketDisconnect
16
+ from pydantic import TypeAdapter, ValidationError
17
+
18
+ from deeptutor.api.contracts.turn_protocol import (
19
+ PROTOCOL_VERSION,
20
+ ClientCommand,
21
+ StartTurnCommand,
22
+ )
23
+ from deeptutor.services.workspace.models import WorkspaceError
24
+
25
+ router = APIRouter()
26
+ logger = logging.getLogger(__name__)
27
+ _CLIENT_COMMAND_ADAPTER = TypeAdapter(ClientCommand)
28
+
29
+
30
+ def _clean_answers(value: Any) -> list[dict[str, Any]] | None:
31
+ if not isinstance(value, list):
32
+ return None
33
+ cleaned: list[dict[str, Any]] = []
34
+ for entry in value:
35
+ if not isinstance(entry, dict):
36
+ continue
37
+ question_id = str(entry.get("questionId") or entry.get("id") or "").strip()
38
+ if question_id:
39
+ cleaned.append({"questionId": question_id, "text": str(entry.get("text") or "")})
40
+ return cleaned or None
41
+
42
+
43
+ @router.websocket("/ws")
44
+ async def unified_websocket(ws: WebSocket) -> None:
45
+ from deeptutor.api.routers.auth import ws_auth_failed, ws_require_auth
46
+ from deeptutor.app.container import get_application_container
47
+ from deeptutor.multi_user.context import reset_current_user
48
+
49
+ user_token = await ws_require_auth(ws)
50
+ if user_token is ws_auth_failed:
51
+ return
52
+
53
+ await ws.accept()
54
+ closed = False
55
+ subscription_tasks: dict[str, asyncio.Task[None]] = {}
56
+
57
+ # Resolve once after authentication. Context variables are copied into
58
+ # subscription tasks, so the socket remains in one stable StoreScope.
59
+ container = getattr(ws.app.state, "application_container", None)
60
+ if container is None:
61
+ container = get_application_container()
62
+ await container.start()
63
+ turns = container.turns
64
+
65
+ async def safe_send(data: dict[str, Any]) -> None:
66
+ nonlocal closed
67
+ if closed:
68
+ return
69
+ try:
70
+ payload = {**data, "protocol_version": PROTOCOL_VERSION}
71
+ await ws.send_text(json.dumps(payload, ensure_ascii=False, default=str))
72
+ except Exception:
73
+ closed = True
74
+
75
+ async def send_protocol_error(
76
+ message: str,
77
+ *,
78
+ error_code: str,
79
+ session_id: str = "",
80
+ turn_id: str = "",
81
+ retryable: bool = False,
82
+ ) -> None:
83
+ await safe_send(
84
+ {
85
+ "type": "protocol_error",
86
+ "error_code": error_code,
87
+ "message": message,
88
+ "retryable": retryable,
89
+ "session_id": session_id,
90
+ "turn_id": turn_id,
91
+ }
92
+ )
93
+
94
+ async def send_error(
95
+ content: str,
96
+ *,
97
+ error_code: str,
98
+ session_id: str = "",
99
+ turn_id: str = "",
100
+ retryable: bool = False,
101
+ terminal: bool = False,
102
+ ) -> None:
103
+ await send_protocol_error(
104
+ content,
105
+ error_code=error_code,
106
+ session_id=session_id,
107
+ turn_id=turn_id,
108
+ retryable=retryable or terminal,
109
+ )
110
+
111
+ async def send_command_ack(
112
+ msg: dict[str, Any],
113
+ *,
114
+ accepted: bool,
115
+ error_code: str = "",
116
+ message: str = "",
117
+ ) -> None:
118
+ await safe_send(
119
+ {
120
+ "type": "command_ack",
121
+ "command_id": str(msg["command_id"]),
122
+ "command_type": str(msg["type"]),
123
+ "accepted": accepted,
124
+ "turn_id": str(msg.get("turn_id") or ""),
125
+ "error_code": error_code,
126
+ "message": message,
127
+ }
128
+ )
129
+
130
+ async def stop_subscription(key: str) -> None:
131
+ task = subscription_tasks.pop(key, None)
132
+ if task is None:
133
+ return
134
+ task.cancel()
135
+ try:
136
+ await task
137
+ except asyncio.CancelledError:
138
+ pass
139
+
140
+ async def subscribe_turn(turn_id: str, after_seq: int = 0) -> None:
141
+ async def _forward() -> None:
142
+ try:
143
+ async for event in turns.subscribe_turn(turn_id, after_seq=after_seq):
144
+ await safe_send(event)
145
+ except asyncio.CancelledError:
146
+ raise
147
+ except Exception as exc:
148
+ logger.exception("Turn subscription failed: %s", turn_id)
149
+ await send_error(
150
+ str(exc),
151
+ error_code="subscription_failed",
152
+ turn_id=turn_id,
153
+ retryable=True,
154
+ )
155
+
156
+ await stop_subscription(turn_id)
157
+ subscription_tasks[turn_id] = asyncio.create_task(_forward())
158
+
159
+ async def subscribe_session(session_id: str, after_seq: int = 0) -> None:
160
+ async def _forward() -> None:
161
+ try:
162
+ async for event in turns.subscribe_session(session_id, after_seq=after_seq):
163
+ await safe_send(event)
164
+ except asyncio.CancelledError:
165
+ raise
166
+ except Exception as exc:
167
+ logger.exception("Session subscription failed: %s", session_id)
168
+ await send_error(
169
+ str(exc),
170
+ error_code="subscription_failed",
171
+ session_id=session_id,
172
+ retryable=True,
173
+ )
174
+
175
+ key = f"session:{session_id}"
176
+ await stop_subscription(key)
177
+ subscription_tasks[key] = asyncio.create_task(_forward())
178
+
179
+ try:
180
+ while not closed:
181
+ raw = await ws.receive_text()
182
+ try:
183
+ decoded = json.loads(raw)
184
+ except json.JSONDecodeError:
185
+ await send_protocol_error("Invalid JSON.", error_code="invalid_json")
186
+ continue
187
+
188
+ if not isinstance(decoded, dict):
189
+ await send_protocol_error(
190
+ "WebSocket commands must be JSON objects.",
191
+ error_code="invalid_command",
192
+ )
193
+ continue
194
+ if decoded.get("protocol_version") != PROTOCOL_VERSION:
195
+ await send_protocol_error(
196
+ f"Unsupported or missing protocol_version; expected {PROTOCOL_VERSION}.",
197
+ error_code="unsupported_protocol_version",
198
+ )
199
+ continue
200
+ try:
201
+ command = _CLIENT_COMMAND_ADAPTER.validate_python(decoded)
202
+ except ValidationError:
203
+ await send_protocol_error(
204
+ "Command does not match the turn protocol.",
205
+ error_code="invalid_command",
206
+ )
207
+ continue
208
+
209
+ # A continuing turn omits workspace_id to inherit its binding.
210
+ # Filling defaults here would turn that omission into an explicit
211
+ # null (and also turn an omitted parent into a root-branch edit).
212
+ msg = (
213
+ command.to_payload()
214
+ if isinstance(command, StartTurnCommand)
215
+ else command.model_dump(mode="python")
216
+ )
217
+
218
+ msg_type = msg.get("type")
219
+
220
+ if msg_type in {"message", "start_turn"}:
221
+ try:
222
+ _, turn = await turns.start_turn(
223
+ {
224
+ key: value
225
+ for key, value in msg.items()
226
+ if key not in {"type", "protocol_version"}
227
+ }
228
+ )
229
+ except (RuntimeError, WorkspaceError) as exc:
230
+ await send_error(
231
+ str(exc),
232
+ error_code="start_turn_rejected",
233
+ session_id=str(msg.get("session_id") or ""),
234
+ terminal=True,
235
+ )
236
+ continue
237
+ await subscribe_turn(turn["id"])
238
+ continue
239
+
240
+ if msg_type == "ping":
241
+ await safe_send({"type": "pong"})
242
+ continue
243
+
244
+ if msg_type in {"subscribe_turn", "resume_from"}:
245
+ turn_id = str(msg.get("turn_id") or "").strip()
246
+ if not turn_id:
247
+ await send_error("Missing turn_id.", error_code="missing_turn_id")
248
+ continue
249
+ after_seq = int(
250
+ (msg.get("seq") if msg_type == "resume_from" else msg.get("after_seq")) or 0
251
+ )
252
+ await subscribe_turn(turn_id, after_seq=after_seq)
253
+ continue
254
+
255
+ if msg_type == "subscribe_session":
256
+ session_id = str(msg.get("session_id") or "").strip()
257
+ if not session_id:
258
+ await send_error("Missing session_id.", error_code="missing_session_id")
259
+ continue
260
+ await subscribe_session(session_id, after_seq=int(msg.get("after_seq") or 0))
261
+ continue
262
+
263
+ if msg_type == "check_active_turn":
264
+ session_id = str(msg.get("session_id") or "").strip()
265
+ if not session_id:
266
+ await send_error("Missing session_id.", error_code="missing_session_id")
267
+ continue
268
+ active = await turns.check_active_turn(session_id)
269
+ await safe_send(
270
+ {
271
+ "type": "active_turn_info",
272
+ "turn_id": str((active or {}).get("turn_id") or ""),
273
+ "status": str((active or {}).get("status") or "none"),
274
+ "owner_id": str((active or {}).get("owner_id") or ""),
275
+ }
276
+ )
277
+ continue
278
+
279
+ if msg_type == "unsubscribe":
280
+ turn_id = str(msg.get("turn_id") or "").strip()
281
+ session_id = str(msg.get("session_id") or "").strip()
282
+ if turn_id:
283
+ await stop_subscription(turn_id)
284
+ if session_id:
285
+ await stop_subscription(f"session:{session_id}")
286
+ continue
287
+
288
+ if msg_type == "cancel_turn":
289
+ turn_id = str(msg.get("turn_id") or "").strip()
290
+ if not turn_id:
291
+ await send_error("Missing turn_id.", error_code="missing_turn_id")
292
+ continue
293
+ accepted = await turns.cancel_turn(turn_id, command_id=str(msg["command_id"]))
294
+ if not accepted:
295
+ await send_command_ack(
296
+ msg,
297
+ accepted=False,
298
+ error_code="turn_not_active",
299
+ message=f"Turn is not active or recoverable: {turn_id}",
300
+ )
301
+ else:
302
+ await send_command_ack(msg, accepted=True)
303
+ continue
304
+
305
+ if msg_type == "submit_user_reply":
306
+ turn_id = str(msg.get("turn_id") or "").strip()
307
+ if not turn_id:
308
+ await send_error("Missing turn_id.", error_code="missing_turn_id")
309
+ continue
310
+ text = msg.get("text")
311
+ accepted = await turns.submit_user_reply(
312
+ turn_id,
313
+ text=str(text) if text is not None else None,
314
+ answers=_clean_answers(msg.get("answers")),
315
+ command_id=str(msg["command_id"]),
316
+ )
317
+ if not accepted:
318
+ await send_command_ack(
319
+ msg,
320
+ accepted=False,
321
+ error_code="turn_not_waiting_input",
322
+ message=f"Turn {turn_id} is not awaiting a user reply.",
323
+ )
324
+ else:
325
+ await send_command_ack(msg, accepted=True)
326
+ continue
327
+
328
+ if msg_type == "regenerate":
329
+ session_id = str(msg.get("session_id") or "").strip()
330
+ if not session_id:
331
+ await send_error("Missing session_id.", error_code="missing_session_id")
332
+ continue
333
+ overrides = msg.get("overrides") if isinstance(msg.get("overrides"), dict) else None
334
+ try:
335
+ _, turn = await turns.regenerate_last_turn(session_id, overrides=overrides)
336
+ except (RuntimeError, WorkspaceError) as exc:
337
+ await send_error(
338
+ str(exc),
339
+ error_code="regenerate_rejected",
340
+ session_id=session_id,
341
+ terminal=True,
342
+ )
343
+ continue
344
+ await subscribe_turn(turn["id"])
345
+ continue
346
+
347
+ if msg_type == "user_input":
348
+ turn_id = str(msg.get("turn_id") or "").strip()
349
+ if not turn_id:
350
+ await send_error(
351
+ "Missing turn_id for user_input.", error_code="missing_turn_id"
352
+ )
353
+ continue
354
+ accepted = await turns.submit_user_input(
355
+ turn_id,
356
+ str(msg.get("content") or ""),
357
+ command_id=str(msg["command_id"]),
358
+ withdraw=str(msg.get("withdraw") or "") or None,
359
+ )
360
+ if not accepted:
361
+ await send_command_ack(
362
+ msg,
363
+ accepted=False,
364
+ error_code="turn_not_active",
365
+ message=f"Turn is not active: {turn_id}",
366
+ )
367
+ else:
368
+ await send_command_ack(msg, accepted=True)
369
+ continue
370
+
371
+ await send_protocol_error(
372
+ f"Unknown type: {msg_type}", error_code="unknown_message_type"
373
+ )
374
+
375
+ except WebSocketDisconnect:
376
+ logger.debug("Client disconnected from /ws")
377
+ except Exception as exc:
378
+ logger.error("Unified WS error: %s", exc, exc_info=True)
379
+ await send_error(str(exc), error_code="internal_error", retryable=True)
380
+ finally:
381
+ closed = True
382
+ for key in list(subscription_tasks):
383
+ await stop_subscription(key)
384
+ if user_token is not None:
385
+ reset_current_user(user_token)