castrel-proxy 0.1.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.
@@ -0,0 +1,1148 @@
1
+ """
2
+ WebSocket Client Module
3
+
4
+ Establishes WebSocket connection with server, receives commands and returns execution results
5
+ """
6
+
7
+ import asyncio
8
+ import json
9
+ import logging
10
+ import os
11
+ import time
12
+ import uuid
13
+ from typing import Optional
14
+
15
+ import aiohttp
16
+
17
+ from ..operations import document
18
+ from ..core.executor import CommandExecutor
19
+ from ..mcp.manager import get_mcp_manager
20
+ from ..security.whitelist import get_whitelist_file_path, is_command_allowed
21
+
22
+ # Configure logging
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ class WebSocketClient:
27
+ """WebSocket client"""
28
+
29
+ def __init__(
30
+ self,
31
+ server_url: str,
32
+ client_id: str,
33
+ verification_code: str,
34
+ workspace_id: str,
35
+ reconnect_interval: float = 5.0,
36
+ ):
37
+ """
38
+ 初始化 WebSocket client
39
+
40
+ Args:
41
+ server_url: Server URL
42
+ client_id: Client unique identifier
43
+ verification_code: Verification code
44
+ workspace_id: Workspace ID
45
+ reconnect_interval: Reconnect interval (seconds)
46
+ """
47
+ self.server_url = server_url
48
+ self.client_id = client_id
49
+ self.verification_code = verification_code
50
+ self.workspace_id = workspace_id
51
+ self.reconnect_interval = reconnect_interval
52
+ self.mcp_manager = get_mcp_manager()
53
+ self.running = False
54
+ self.ws: Optional[aiohttp.ClientWebSocketResponse] = None
55
+ self.session: Optional[aiohttp.ClientSession] = None
56
+ self.heartbeat_task: Optional[asyncio.Task] = None
57
+ self.heartbeat_interval = 30.0 # 30seconds,Ensure enough heartbeats before server timeout
58
+
59
+ def _get_ws_url(self) -> str:
60
+ """Get WebSocket URL"""
61
+ # Convert http/https to ws/wss
62
+ ws_url = self.server_url.replace("https://", "wss://").replace("http://", "ws://")
63
+ ws_url = ws_url.rstrip("/")
64
+
65
+ # Add client authentication parameters
66
+ return f"{ws_url}/api/v1/bridge/ws?client_id={self.client_id}&workspace_id={self.workspace_id}&verification_code={self.verification_code}"
67
+
68
+ def _log_operation(
69
+ self,
70
+ session_id: str,
71
+ operation_type: str,
72
+ operation: str,
73
+ arguments: any = None,
74
+ result: any = None,
75
+ success: bool = True,
76
+ elapsed: float = 0.0,
77
+ error: str = None,
78
+ ):
79
+ """
80
+ Log operation to terminal.log
81
+
82
+ Args:
83
+ session_id: Session ID
84
+ operation_type: Operation type(MCP_TOOL, DOC_READ, DOC_WRITE, DOC_EDIT)
85
+ operation: Operation name
86
+ arguments: Arguments
87
+ result: Result
88
+ success: Whether successful
89
+ elapsed: Execution time
90
+ error: Error message
91
+ """
92
+ from datetime import datetime
93
+
94
+ try:
95
+ # Create session directory
96
+ home_dir = os.path.expanduser("~")
97
+ session_dir = os.path.join(home_dir, ".castrel", session_id)
98
+ os.makedirs(session_dir, exist_ok=True)
99
+ log_file = os.path.join(session_dir, "terminal.log")
100
+
101
+ timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
102
+
103
+ log_entry = f"""[{timestamp}] {operation_type}: {operation}
104
+ SUCCESS: {success}
105
+ DURATION: {elapsed:.2f}s
106
+ """
107
+
108
+ if arguments:
109
+ import json
110
+
111
+ log_entry += f" ARGUMENTS:\n {json.dumps(arguments, ensure_ascii=False, indent=2).replace(chr(10), chr(10) + ' ')}\n"
112
+
113
+ if result:
114
+ import json
115
+
116
+ result_str = json.dumps(result, ensure_ascii=False, indent=2)[:500] # Limit length
117
+ log_entry += f" RESULT:\n {result_str.replace(chr(10), chr(10) + ' ')}\n"
118
+
119
+ if error:
120
+ log_entry += f" ERROR:\n {error}\n"
121
+
122
+ log_entry += "---\n\n"
123
+
124
+ # Append to log file
125
+ with open(log_file, "a", encoding="utf-8") as f:
126
+ f.write(log_entry)
127
+
128
+ except Exception as e:
129
+ # Logging failure should not affect operation execution
130
+ logger.warning(f"Failed to log operation: {e}")
131
+
132
+ async def _handle_command(self, message: dict) -> Optional[dict]:
133
+ """
134
+ Handle server commands
135
+
136
+ Args:
137
+ message: Server message
138
+
139
+ Returns:
140
+ Optional[dict]: Response message, return None if no response needed None
141
+ """
142
+ message_id = message.get("id")
143
+ message_type = message.get("type")
144
+ timestamp = message.get("timestamp")
145
+
146
+ logger.info(
147
+ f"[CLIENT-MSG-RECV] Received message: message_id={message_id}, "
148
+ f"message_type={message_type}, timestamp={timestamp}, client_id={self.client_id}"
149
+ )
150
+
151
+ if message_type == "connected":
152
+ # Connection success message
153
+ session_id = message.get("session_id", "")
154
+ msg = message.get("message", "")
155
+ logger.info(
156
+ f"[CLIENT-CONNECTED] Connection established: session_id={session_id}, "
157
+ f"message={msg}, client_id={self.client_id}"
158
+ )
159
+ # No response needed
160
+ return None
161
+
162
+ elif message_type == "local_tool_call":
163
+ # Handle local command call
164
+ data = message.get("data", {})
165
+ command = data.get("command", "")
166
+ args = data.get("args", [])
167
+ cwd = data.get("cwd")
168
+ timeout = data.get("timeout", 300)
169
+ session_id = data.get("session_id", "")
170
+
171
+ logger.info(
172
+ f"[CLIENT-LOCAL-CALL] Local tool call received: message_id={message_id}, "
173
+ f"command={command}, args={args}, cwd={cwd}, session_id={session_id}, timeout={timeout}s, "
174
+ f"client_id={self.client_id}"
175
+ )
176
+
177
+ return await self._execute_local_command(
178
+ message_id=message_id,
179
+ command=command,
180
+ args=args,
181
+ cwd=cwd,
182
+ session_id=session_id,
183
+ timeout=timeout,
184
+ )
185
+
186
+ elif message_type == "mcp_tool_call":
187
+ # Handle MCP tool call
188
+ data = message.get("data", {})
189
+ server_name = data.get("server_name", "")
190
+ tool_name = data.get("tool_name", "")
191
+ arguments = data.get("arguments", {})
192
+ session_id = data.get("session_id", "")
193
+
194
+ logger.info(
195
+ f"[CLIENT-MCP-CALL] MCP tool call received: message_id={message_id}, "
196
+ f"server={server_name}, tool={tool_name}, session_id={session_id}, arguments={arguments}, "
197
+ f"client_id={self.client_id}"
198
+ )
199
+
200
+ return await self._execute_mcp_tool(
201
+ message_id=message_id,
202
+ server_name=server_name,
203
+ tool_name=tool_name,
204
+ session_id=session_id,
205
+ arguments=arguments,
206
+ )
207
+
208
+ elif message_type == "doc_read_call":
209
+ # Handle document read call
210
+ data = message.get("data", {})
211
+ file_path = data.get("file_path", "")
212
+ encoding = data.get("encoding")
213
+ session_id = data.get("session_id", "")
214
+
215
+ logger.info(
216
+ f"[CLIENT-DOC-READ-CALL] Doc read call received: message_id={message_id}, "
217
+ f"file_path={file_path}, session_id={session_id}, encoding={encoding}, client_id={self.client_id}"
218
+ )
219
+
220
+ return await self._execute_doc_read(
221
+ message_id=message_id,
222
+ file_path=file_path,
223
+ session_id=session_id,
224
+ encoding=encoding,
225
+ )
226
+
227
+ elif message_type == "doc_write_call":
228
+ # Handle document write call
229
+ data = message.get("data", {})
230
+ file_path = data.get("file_path", "")
231
+ content = data.get("content", "")
232
+ encoding = data.get("encoding", "utf-8")
233
+ create_dirs = data.get("create_dirs", True)
234
+ session_id = data.get("session_id", "")
235
+
236
+ logger.info(
237
+ f"[CLIENT-DOC-WRITE-CALL] Doc write call received: message_id={message_id}, "
238
+ f"file_path={file_path}, content_len={len(content)}, session_id={session_id}, encoding={encoding}, "
239
+ f"create_dirs={create_dirs}, client_id={self.client_id}"
240
+ )
241
+
242
+ return await self._execute_doc_write(
243
+ message_id=message_id,
244
+ file_path=file_path,
245
+ session_id=session_id,
246
+ content=content,
247
+ encoding=encoding,
248
+ create_dirs=create_dirs,
249
+ )
250
+
251
+ elif message_type == "doc_edit_call":
252
+ # Handle document edit call
253
+ data = message.get("data", {})
254
+ file_path = data.get("file_path", "")
255
+ operation = data.get("operation", "")
256
+ new_content = data.get("new_content", "")
257
+ old_content = data.get("old_content")
258
+ encoding = data.get("encoding")
259
+ session_id = data.get("session_id", "")
260
+
261
+ logger.info(
262
+ f"[CLIENT-DOC-EDIT-CALL] Doc edit call received: message_id={message_id}, "
263
+ f"file_path={file_path}, operation={operation}, session_id={session_id}, encoding={encoding}, client_id={self.client_id}"
264
+ )
265
+
266
+ return await self._execute_doc_edit(
267
+ message_id=message_id,
268
+ file_path=file_path,
269
+ session_id=session_id,
270
+ operation=operation,
271
+ new_content=new_content,
272
+ old_content=old_content,
273
+ encoding=encoding,
274
+ )
275
+
276
+ elif message_type == "ping":
277
+ # Heartbeat from server, response needed
278
+ logger.debug(f"[CLIENT-PING-RECV] Received ping: message_id={message_id}, client_id={self.client_id}")
279
+ return {"id": message_id, "type": "pong"}
280
+
281
+ elif message_type == "pong":
282
+ # Server response to client heartbeat
283
+ logger.debug(f"[CLIENT-PONG-RECV] Received pong: message_id={message_id}, client_id={self.client_id}")
284
+ # No response needed
285
+ return None
286
+
287
+ else:
288
+ # Unknown command type
289
+ logger.warning(
290
+ f"[CLIENT-MSG-UNKNOWN] Unknown message type: message_id={message_id}, "
291
+ f"message_type={message_type}, client_id={self.client_id}"
292
+ )
293
+ return {
294
+ "id": message_id,
295
+ "type": "error",
296
+ "error": f"Unknown message type: {message_type}",
297
+ }
298
+
299
+ async def _send_heartbeat(self):
300
+ """Send heartbeat periodically"""
301
+ logger.info(
302
+ f"[CLIENT-HEARTBEAT-START] Heartbeat task started: interval={self.heartbeat_interval}s, "
303
+ f"client_id={self.client_id}"
304
+ )
305
+
306
+ while self.running and self.ws and not self.ws.closed:
307
+ try:
308
+ # Send heartbeat message
309
+ heartbeat_msg = {
310
+ "id": str(uuid.uuid4()),
311
+ "type": "ping",
312
+ "timestamp": int(time.time() * 1000),
313
+ }
314
+ logger.debug(
315
+ f"[CLIENT-HEARTBEAT-SEND] Sending heartbeat: message_id={heartbeat_msg['id']}, "
316
+ f"client_id={self.client_id}"
317
+ )
318
+ await self.ws.send_json(heartbeat_msg)
319
+ logger.debug(
320
+ f"[CLIENT-HEARTBEAT-SENT] Heartbeat sent: message_id={heartbeat_msg['id']}, "
321
+ f"client_id={self.client_id}"
322
+ )
323
+
324
+ # Wait for next heartbeat
325
+ await asyncio.sleep(self.heartbeat_interval)
326
+
327
+ except Exception as e:
328
+ logger.error(
329
+ f"[CLIENT-HEARTBEAT-ERROR] Failed to send heartbeat: error={e}, client_id={self.client_id}",
330
+ exc_info=True,
331
+ )
332
+ break
333
+
334
+ logger.info(f"[CLIENT-HEARTBEAT-STOP] Heartbeat task stopped: client_id={self.client_id}")
335
+
336
+ async def _execute_local_command(
337
+ self,
338
+ message_id: str,
339
+ command: str,
340
+ session_id: str,
341
+ args: list = None,
342
+ cwd: Optional[str] = None,
343
+ timeout: int = 300,
344
+ ) -> dict:
345
+ """
346
+ Execute local command
347
+
348
+ Args:
349
+ message_id: 消息ID
350
+ command: 命令名称
351
+ session_id: 聊天Session ID(required)
352
+ args: 命令Arguments列表
353
+ cwd: 工作目录
354
+ timeout: 超时时间(seconds)
355
+
356
+ Returns:
357
+ dict: 响应消息
358
+ """
359
+ start_time = time.time()
360
+ try:
361
+ # 验证 session_id
362
+ if not session_id:
363
+ logger.error(f"[CLIENT-LOCAL-EXEC-ERROR] session_id is required: message_id={message_id}")
364
+ return {
365
+ "id": message_id,
366
+ "type": "local_tool_result",
367
+ "success": False,
368
+ "data": {
369
+ "exit_code": -1,
370
+ "stdout": "",
371
+ "stderr": "session_id is required for command execution",
372
+ "execution_time": 0.0,
373
+ },
374
+ }
375
+
376
+ if args is None:
377
+ args = []
378
+
379
+ # 展开Arguments中的 ~ 路径和环境变量
380
+ expanded_args = []
381
+ for arg in args:
382
+ # 只对看起来像路径的Arguments进行展开(包含 ~ 或 $)
383
+ if "~" in arg or "$" in arg:
384
+ expanded_args.append(os.path.expanduser(os.path.expandvars(arg)))
385
+ else:
386
+ expanded_args.append(arg)
387
+
388
+ # 构建完整命令
389
+ if expanded_args:
390
+ full_command = f"{command} {' '.join(expanded_args)}"
391
+ else:
392
+ full_command = command
393
+
394
+ # Whitelist check
395
+ is_allowed, blocked_commands = is_command_allowed(full_command)
396
+ if not is_allowed:
397
+ whitelist_path = get_whitelist_file_path()
398
+ blocked_list = ", ".join(blocked_commands) if blocked_commands else command
399
+ error_msg = (
400
+ f"Command execution rejected。Following commands not in whitelist: {blocked_list}\n"
401
+ f"Please add required commands to whitelist configuration file: {whitelist_path}"
402
+ )
403
+ logger.warning(
404
+ f"[CLIENT-LOCAL-EXEC-BLOCKED] Commands not in whitelist: message_id={message_id}, "
405
+ f"blocked_commands={blocked_commands}, full_command={full_command[:200]}, "
406
+ f"whitelist_path={whitelist_path}, client_id={self.client_id}"
407
+ )
408
+ return {
409
+ "id": message_id,
410
+ "type": "local_tool_result",
411
+ "success": False,
412
+ "data": {
413
+ "exit_code": -3,
414
+ "stdout": "",
415
+ "stderr": error_msg,
416
+ "execution_time": 0.0,
417
+ },
418
+ }
419
+
420
+ logger.info(
421
+ f"[CLIENT-LOCAL-EXEC-START] Executing local command: message_id={message_id}, "
422
+ f"command={full_command}, session_id={session_id}, cwd={cwd}, timeout={timeout}s, "
423
+ f"client_id={self.client_id}"
424
+ )
425
+
426
+ # Create session-specific executor
427
+ executor = CommandExecutor(session_id=session_id, timeout=timeout)
428
+
429
+ # Execute command(Pass cwd to execute method if specified)
430
+ result = await executor.execute(full_command, cwd=cwd)
431
+
432
+ elapsed = time.time() - start_time
433
+ logger.info(
434
+ f"[CLIENT-LOCAL-EXEC-SUCCESS] Local command completed: message_id={message_id}, "
435
+ f"exit_code={result.exit_code}, elapsed={elapsed:.2f}s, "
436
+ f"stdout_len={len(result.stdout)}, stderr_len={len(result.stderr)}, client_id={self.client_id}"
437
+ )
438
+
439
+ return {
440
+ "id": message_id,
441
+ "type": "local_tool_result",
442
+ "success": result.exit_code == 0,
443
+ "data": result.to_dict(),
444
+ }
445
+
446
+ except Exception as e:
447
+ elapsed = time.time() - start_time
448
+ logger.error(
449
+ f"[CLIENT-LOCAL-EXEC-ERROR] Local command execution failed: message_id={message_id}, "
450
+ f"error={e}, elapsed={elapsed:.2f}s, client_id={self.client_id}",
451
+ exc_info=True,
452
+ )
453
+ return {
454
+ "id": message_id,
455
+ "type": "local_tool_result",
456
+ "success": False,
457
+ "data": {
458
+ "exit_code": -1,
459
+ "stdout": "",
460
+ "stderr": f"Execute local command失败: {str(e)}",
461
+ "execution_time": 0.0,
462
+ },
463
+ }
464
+
465
+ async def _execute_mcp_tool(
466
+ self,
467
+ message_id: str,
468
+ server_name: str,
469
+ tool_name: str,
470
+ session_id: str,
471
+ arguments: dict,
472
+ ) -> dict:
473
+ """
474
+ Execute MCP tool
475
+
476
+ Args:
477
+ message_id: 消息ID
478
+ server_name: MCP 服务器名称
479
+ tool_name: 工具名称
480
+ session_id: 聊天Session ID(required)
481
+ arguments: 工具Arguments
482
+
483
+ Returns:
484
+ dict: 响应消息
485
+ """
486
+ start_time = time.time()
487
+ try:
488
+ # 验证 session_id
489
+ if not session_id:
490
+ logger.error(f"[CLIENT-MCP-EXEC-ERROR] session_id is required: message_id={message_id}")
491
+ return {
492
+ "id": message_id,
493
+ "type": "mcp_tool_result",
494
+ "success": False,
495
+ "data": {
496
+ "server_name": server_name,
497
+ "tool_name": tool_name,
498
+ "result": None,
499
+ "error": "session_id is required for MCP tool execution",
500
+ },
501
+ }
502
+
503
+ logger.info(
504
+ f"[CLIENT-MCP-EXEC-START] Executing MCP tool: message_id={message_id}, "
505
+ f"server={server_name}, tool={tool_name}, session_id={session_id}, arguments={arguments}, "
506
+ f"client_id={self.client_id}"
507
+ )
508
+
509
+ # Call MCP tool
510
+ if not self.mcp_manager.client:
511
+ logger.error(
512
+ f"[CLIENT-MCP-EXEC-ERROR] MCP client not initialized: message_id={message_id}, "
513
+ f"client_id={self.client_id}"
514
+ )
515
+ return {
516
+ "id": message_id,
517
+ "type": "mcp_tool_result",
518
+ "success": False,
519
+ "data": {
520
+ "server_name": server_name,
521
+ "tool_name": tool_name,
522
+ "result": None,
523
+ "error": "MCP 客户端未初始化",
524
+ },
525
+ }
526
+
527
+ # 执行工具
528
+ tools = await self.mcp_manager.client.get_tools(server_name=server_name)
529
+ current_tool = None
530
+ # Iterate through tool_name to get corresponding tool
531
+ for tool in tools:
532
+ if tool.name == tool_name:
533
+ current_tool = tool
534
+ break
535
+ if not current_tool:
536
+ logger.error(
537
+ f"[CLIENT-MCP-EXEC-ERROR] MCP tool not found: message_id={message_id}, "
538
+ f"server={server_name}, tool={tool_name}, available_tools={[t.name for t in tools]}, "
539
+ f"client_id={self.client_id}"
540
+ )
541
+ return {
542
+ "id": message_id,
543
+ "type": "mcp_tool_result",
544
+ "success": False,
545
+ "data": {
546
+ "server_name": server_name,
547
+ "tool_name": tool_name,
548
+ "result": None,
549
+ "error": "Execute MCP tool失败: tool不存在",
550
+ },
551
+ }
552
+
553
+ result = await current_tool.ainvoke(input=arguments)
554
+
555
+ elapsed = time.time() - start_time
556
+ logger.info(
557
+ f"[CLIENT-MCP-EXEC-SUCCESS] MCP tool completed: message_id={message_id}, "
558
+ f"server={server_name}, tool={tool_name}, elapsed={elapsed:.2f}s, client_id={self.client_id}"
559
+ )
560
+
561
+ # Log to terminal.log
562
+ self._log_operation(
563
+ session_id=session_id,
564
+ operation_type="MCP_TOOL",
565
+ operation=f"{server_name}:{tool_name}",
566
+ arguments=arguments,
567
+ result=result,
568
+ success=True,
569
+ elapsed=elapsed,
570
+ )
571
+
572
+ return {
573
+ "id": message_id,
574
+ "type": "mcp_tool_result",
575
+ "success": True,
576
+ "data": {
577
+ "server_name": server_name,
578
+ "tool_name": tool_name,
579
+ "result": result,
580
+ },
581
+ }
582
+
583
+ except Exception as e:
584
+ elapsed = time.time() - start_time
585
+ logger.error(
586
+ f"[CLIENT-MCP-EXEC-ERROR] MCP tool execution failed: message_id={message_id}, "
587
+ f"server={server_name}, tool={tool_name}, error={e}, elapsed={elapsed:.2f}s, "
588
+ f"client_id={self.client_id}",
589
+ exc_info=True,
590
+ )
591
+ return {
592
+ "id": message_id,
593
+ "type": "mcp_tool_result",
594
+ "success": False,
595
+ "data": {
596
+ "server_name": server_name,
597
+ "tool_name": tool_name,
598
+ "result": None,
599
+ "error": f"Execute MCP tool失败: {str(e)}",
600
+ },
601
+ }
602
+
603
+ async def _execute_doc_read(
604
+ self,
605
+ message_id: str,
606
+ file_path: str,
607
+ session_id: str,
608
+ encoding: Optional[str] = None,
609
+ ) -> dict:
610
+ """
611
+ Execute document read
612
+
613
+ Args:
614
+ message_id: 消息ID
615
+ file_path: 文件路径
616
+ session_id: 聊天Session ID
617
+ encoding: 文件编码
618
+
619
+ Returns:
620
+ dict: 响应消息
621
+ """
622
+ start_time = time.time()
623
+ try:
624
+ logger.info(
625
+ f"[CLIENT-DOC-READ-EXEC-START] Executing doc read: message_id={message_id}, "
626
+ f"file_path={file_path}, session_id={session_id}, encoding={encoding}, client_id={self.client_id}"
627
+ )
628
+
629
+ # Call document.read_document
630
+ result = document.read_document(file_path=file_path, encoding=encoding)
631
+
632
+ elapsed = time.time() - start_time
633
+ logger.info(
634
+ f"[CLIENT-DOC-READ-EXEC-SUCCESS] Doc read completed: message_id={message_id}, "
635
+ f"success={result.get('success')}, elapsed={elapsed:.2f}s, client_id={self.client_id}"
636
+ )
637
+
638
+ # Log to terminal.log
639
+ self._log_operation(
640
+ session_id=session_id,
641
+ operation_type="DOC_READ",
642
+ operation=file_path,
643
+ arguments={"encoding": encoding},
644
+ result={"size": result.get("size"), "encoding": result.get("encoding")},
645
+ success=result.get("success", False),
646
+ elapsed=elapsed,
647
+ error=result.get("error"),
648
+ )
649
+
650
+ return {
651
+ "id": message_id,
652
+ "type": "doc_read_result",
653
+ "success": result.get("success", False),
654
+ "data": {
655
+ "content": result.get("content"),
656
+ "encoding": result.get("encoding"),
657
+ "size": result.get("size"),
658
+ "error": result.get("error"),
659
+ },
660
+ }
661
+
662
+ except Exception as e:
663
+ elapsed = time.time() - start_time
664
+ logger.error(
665
+ f"[CLIENT-DOC-READ-EXEC-ERROR] Doc read execution failed: message_id={message_id}, "
666
+ f"error={e}, elapsed={elapsed:.2f}s, client_id={self.client_id}",
667
+ exc_info=True,
668
+ )
669
+
670
+ # Log error to terminal.log
671
+ self._log_operation(
672
+ session_id=session_id,
673
+ operation_type="DOC_READ",
674
+ operation=file_path,
675
+ arguments={"encoding": encoding},
676
+ result=None,
677
+ success=False,
678
+ elapsed=elapsed,
679
+ error=str(e),
680
+ )
681
+
682
+ return {
683
+ "id": message_id,
684
+ "type": "doc_read_result",
685
+ "success": False,
686
+ "data": {
687
+ "content": None,
688
+ "encoding": None,
689
+ "size": None,
690
+ "error": f"Execute document read失败: {str(e)}",
691
+ },
692
+ }
693
+
694
+ async def _execute_doc_write(
695
+ self,
696
+ message_id: str,
697
+ file_path: str,
698
+ session_id: str,
699
+ content: str,
700
+ encoding: str = "utf-8",
701
+ create_dirs: bool = True,
702
+ ) -> dict:
703
+ """
704
+ Execute document write
705
+
706
+ Args:
707
+ message_id: 消息ID
708
+ file_path: 文件路径
709
+ session_id: 聊天Session ID
710
+ content: 文件内容
711
+ encoding: 文件编码
712
+ create_dirs: 是否创建父目录
713
+
714
+ Returns:
715
+ dict: 响应消息
716
+ """
717
+ start_time = time.time()
718
+ try:
719
+ logger.info(
720
+ f"[CLIENT-DOC-WRITE-EXEC-START] Executing doc write: message_id={message_id}, "
721
+ f"file_path={file_path}, content_len={len(content)}, session_id={session_id}, encoding={encoding}, "
722
+ f"create_dirs={create_dirs}, client_id={self.client_id}"
723
+ )
724
+
725
+ # Call document.write_document
726
+ result = document.write_document(
727
+ file_path=file_path,
728
+ content=content,
729
+ encoding=encoding,
730
+ create_dirs=create_dirs,
731
+ )
732
+
733
+ elapsed = time.time() - start_time
734
+ logger.info(
735
+ f"[CLIENT-DOC-WRITE-EXEC-SUCCESS] Doc write completed: message_id={message_id}, "
736
+ f"success={result.get('success')}, elapsed={elapsed:.2f}s, client_id={self.client_id}"
737
+ )
738
+
739
+ # Log to terminal.log
740
+ self._log_operation(
741
+ session_id=session_id,
742
+ operation_type="DOC_WRITE",
743
+ operation=file_path,
744
+ arguments={
745
+ "content_length": len(content),
746
+ "encoding": encoding,
747
+ "create_dirs": create_dirs,
748
+ },
749
+ result={"size": result.get("size"), "path": result.get("path")},
750
+ success=result.get("success", False),
751
+ elapsed=elapsed,
752
+ error=result.get("error"),
753
+ )
754
+
755
+ return {
756
+ "id": message_id,
757
+ "type": "doc_write_result",
758
+ "success": result.get("success", False),
759
+ "data": {
760
+ "path": result.get("path"),
761
+ "size": result.get("size"),
762
+ "error": result.get("error"),
763
+ },
764
+ }
765
+
766
+ except Exception as e:
767
+ elapsed = time.time() - start_time
768
+ logger.error(
769
+ f"[CLIENT-DOC-WRITE-EXEC-ERROR] Doc write execution failed: message_id={message_id}, "
770
+ f"error={e}, elapsed={elapsed:.2f}s, client_id={self.client_id}",
771
+ exc_info=True,
772
+ )
773
+
774
+ # Log error to terminal.log
775
+ self._log_operation(
776
+ session_id=session_id,
777
+ operation_type="DOC_WRITE",
778
+ operation=file_path,
779
+ arguments={
780
+ "content_length": len(content),
781
+ "encoding": encoding,
782
+ "create_dirs": create_dirs,
783
+ },
784
+ result=None,
785
+ success=False,
786
+ elapsed=elapsed,
787
+ error=str(e),
788
+ )
789
+
790
+ return {
791
+ "id": message_id,
792
+ "type": "doc_write_result",
793
+ "success": False,
794
+ "data": {
795
+ "path": None,
796
+ "size": None,
797
+ "error": f"Execute document write失败: {str(e)}",
798
+ },
799
+ }
800
+
801
+ async def _execute_doc_edit(
802
+ self,
803
+ message_id: str,
804
+ file_path: str,
805
+ session_id: str,
806
+ operation: str,
807
+ new_content: str,
808
+ old_content: Optional[str] = None,
809
+ encoding: Optional[str] = None,
810
+ ) -> dict:
811
+ """
812
+ Execute document edit
813
+
814
+ Args:
815
+ message_id: 消息ID
816
+ file_path: 文件路径
817
+ session_id: 聊天Session ID
818
+ operation: Operation type(replace, append, prepend)
819
+ new_content: 新内容
820
+ old_content: 旧内容(Only needed for replace operation)
821
+ encoding: 文件编码
822
+
823
+ Returns:
824
+ dict: 响应消息
825
+ """
826
+ start_time = time.time()
827
+ try:
828
+ logger.info(
829
+ f"[CLIENT-DOC-EDIT-EXEC-START] Executing doc edit: message_id={message_id}, "
830
+ f"file_path={file_path}, operation={operation}, session_id={session_id}, encoding={encoding}, client_id={self.client_id}"
831
+ )
832
+
833
+ # Call document.edit_document
834
+ result = document.edit_document(
835
+ file_path=file_path,
836
+ operation=operation,
837
+ new_content=new_content,
838
+ old_content=old_content,
839
+ encoding=encoding,
840
+ )
841
+
842
+ elapsed = time.time() - start_time
843
+ logger.info(
844
+ f"[CLIENT-DOC-EDIT-EXEC-SUCCESS] Doc edit completed: message_id={message_id}, "
845
+ f"success={result.get('success')}, operation={operation}, elapsed={elapsed:.2f}s, "
846
+ f"client_id={self.client_id}"
847
+ )
848
+
849
+ # Log to terminal.log
850
+ self._log_operation(
851
+ session_id=session_id,
852
+ operation_type="DOC_EDIT",
853
+ operation=f"{operation}:{file_path}",
854
+ arguments={
855
+ "new_content_length": len(new_content),
856
+ "old_content_length": len(old_content) if old_content else 0,
857
+ "encoding": encoding,
858
+ },
859
+ result={"size": result.get("size"), "path": result.get("path")},
860
+ success=result.get("success", False),
861
+ elapsed=elapsed,
862
+ error=result.get("error"),
863
+ )
864
+
865
+ return {
866
+ "id": message_id,
867
+ "type": "doc_edit_result",
868
+ "success": result.get("success", False),
869
+ "data": {
870
+ "operation": result.get("operation"),
871
+ "size": result.get("size"),
872
+ "path": result.get("path"),
873
+ "error": result.get("error"),
874
+ },
875
+ }
876
+
877
+ except Exception as e:
878
+ elapsed = time.time() - start_time
879
+ logger.error(
880
+ f"[CLIENT-DOC-EDIT-EXEC-ERROR] Doc edit execution failed: message_id={message_id}, "
881
+ f"error={e}, elapsed={elapsed:.2f}s, client_id={self.client_id}",
882
+ exc_info=True,
883
+ )
884
+
885
+ # Log error to terminal.log
886
+ self._log_operation(
887
+ session_id=session_id,
888
+ operation_type="DOC_EDIT",
889
+ operation=f"{operation}:{file_path}",
890
+ arguments={
891
+ "new_content_length": len(new_content),
892
+ "old_content_length": len(old_content) if old_content else 0,
893
+ "encoding": encoding,
894
+ },
895
+ result=None,
896
+ success=False,
897
+ elapsed=elapsed,
898
+ error=str(e),
899
+ )
900
+
901
+ return {
902
+ "id": message_id,
903
+ "type": "doc_edit_result",
904
+ "success": False,
905
+ "data": {
906
+ "operation": operation,
907
+ "size": None,
908
+ "path": None,
909
+ "error": f"Execute document edit失败: {str(e)}",
910
+ },
911
+ }
912
+
913
+ async def _listen(self):
914
+ """监听Server message"""
915
+ try:
916
+ logger.info(f"[CLIENT-LISTEN-START] Started listening for messages: client_id={self.client_id}")
917
+
918
+ async for msg in self.ws:
919
+ if msg.type == aiohttp.WSMsgType.TEXT:
920
+ try:
921
+ # Parse message
922
+ data = json.loads(msg.data)
923
+ logger.debug(f"[CLIENT-WS-RECV] Received raw message: data={data}, client_id={self.client_id}")
924
+
925
+ # Put command processing in background task to avoid blocking message receive loop
926
+ # This ensures other messages (like ping/pong) can still be received and processed during long tasks
927
+ asyncio.create_task(self._handle_and_respond(data))
928
+
929
+ except json.JSONDecodeError as e:
930
+ logger.error(
931
+ f"[CLIENT-MSG-PARSE-ERROR] Failed to parse message: error={e}, "
932
+ f"raw_data={msg.data}, client_id={self.client_id}"
933
+ )
934
+ except Exception as e:
935
+ logger.error(
936
+ f"[CLIENT-MSG-HANDLE-ERROR] Error handling message: error={e}, client_id={self.client_id}",
937
+ exc_info=True,
938
+ )
939
+
940
+ elif msg.type == aiohttp.WSMsgType.ERROR:
941
+ logger.error(
942
+ f"[CLIENT-WS-ERROR] WebSocket error: exception={self.ws.exception()}, "
943
+ f"client_id={self.client_id}"
944
+ )
945
+ break
946
+
947
+ elif msg.type == aiohttp.WSMsgType.CLOSED:
948
+ logger.info(f"[CLIENT-WS-CLOSED] WebSocket connection closed: client_id={self.client_id}")
949
+ break
950
+
951
+ except Exception as e:
952
+ logger.error(
953
+ f"[CLIENT-LISTEN-ERROR] Error in message listener: error={e}, client_id={self.client_id}",
954
+ exc_info=True,
955
+ )
956
+
957
+ async def _handle_and_respond(self, data: dict):
958
+ """
959
+ Handle command and send response (run as background task)
960
+
961
+ Put command handling and response sending in separate task to avoid blocking _listen() loop。
962
+ 这样可以确保在长任务执行期间:
963
+ 1. Message receive loop continues running, can receive server PING/PONG messages
964
+ 2. Heartbeat task can send normally
965
+ 3. WebSocket Connection stays active
966
+
967
+ Args:
968
+ data: Received message data
969
+ """
970
+ try:
971
+ # Handle command
972
+ response = await self._handle_command(data)
973
+
974
+ # Send response(if any)
975
+ if response is not None:
976
+ logger.info(
977
+ f"[CLIENT-RESPONSE-SEND] Sending response: message_id={response.get('id')}, "
978
+ f"type={response.get('type')}, success={response.get('success')}, "
979
+ f"client_id={self.client_id}"
980
+ )
981
+ await self.ws.send_json(response)
982
+ logger.info(
983
+ f"[CLIENT-RESPONSE-SENT] Response sent successfully: message_id={response.get('id')}, "
984
+ f"client_id={self.client_id}"
985
+ )
986
+ else:
987
+ logger.debug(
988
+ f"[CLIENT-NO-RESPONSE] No response needed for message: "
989
+ f"message_id={data.get('id')}, type={data.get('type')}, client_id={self.client_id}"
990
+ )
991
+ except Exception as e:
992
+ logger.error(
993
+ f"[CLIENT-HANDLE-RESPOND-ERROR] Error in handle_and_respond: error={e}, "
994
+ f"message_id={data.get('id')}, client_id={self.client_id}",
995
+ exc_info=True,
996
+ )
997
+
998
+ async def connect(self):
999
+ """Establish WebSocket connection"""
1000
+ ws_url = self._get_ws_url()
1001
+ logger.info(
1002
+ f"[CLIENT-CONNECT-START] Attempting to connect to server: url={ws_url}, "
1003
+ f"client_id={self.client_id}, workspace_id={self.workspace_id}"
1004
+ )
1005
+
1006
+ try:
1007
+ # Don't set global timeout when creating ClientSession
1008
+ # This ensures long-running commands won't be interrupted by timeout
1009
+ self.session = aiohttp.ClientSession()
1010
+ logger.debug(f"[CLIENT-SESSION-CREATED] aiohttp session created: client_id={self.client_id}")
1011
+
1012
+ # Establish WebSocket connection,配置适合长时间任务的Arguments
1013
+ self.ws = await self.session.ws_connect(
1014
+ ws_url,
1015
+ heartbeat=self.heartbeat_interval, # Enable WebSocket protocol-level heartbeat(PING/PONG)
1016
+ timeout=900.0, # 15minute timeout,Support long-running commands
1017
+ autoclose=False, # Don't auto-close connection
1018
+ autoping=True, # Auto-respond to server PING
1019
+ )
1020
+ logger.info(
1021
+ f"[CLIENT-CONNECT-SUCCESS] WebSocket connection established: client_id={self.client_id}, "
1022
+ f"ws_closed={self.ws.closed}, heartbeat={self.heartbeat_interval}s"
1023
+ )
1024
+
1025
+ # Start heartbeat task
1026
+ self.heartbeat_task = asyncio.create_task(self._send_heartbeat())
1027
+ logger.info(
1028
+ f"[CLIENT-HEARTBEAT-TASK] Heartbeat task started: interval={self.heartbeat_interval}s, "
1029
+ f"client_id={self.client_id}"
1030
+ )
1031
+
1032
+ return True
1033
+
1034
+ except Exception as e:
1035
+ logger.error(
1036
+ f"[CLIENT-CONNECT-ERROR] Failed to connect: error={e}, client_id={self.client_id}",
1037
+ exc_info=True,
1038
+ )
1039
+ if self.session:
1040
+ await self.session.close()
1041
+ return False
1042
+
1043
+ async def disconnect(self):
1044
+ """Disconnect WebSocket connection"""
1045
+ logger.info(f"[CLIENT-DISCONNECT-START] Starting disconnect process: client_id={self.client_id}")
1046
+
1047
+ # Stop heartbeat task
1048
+ if self.heartbeat_task and not self.heartbeat_task.done():
1049
+ logger.debug(f"[CLIENT-DISCONNECT-HEARTBEAT] Cancelling heartbeat task: client_id={self.client_id}")
1050
+ self.heartbeat_task.cancel()
1051
+ try:
1052
+ await self.heartbeat_task
1053
+ except asyncio.CancelledError:
1054
+ pass
1055
+ logger.info(f"[CLIENT-DISCONNECT-HEARTBEAT] Heartbeat task stopped: client_id={self.client_id}")
1056
+
1057
+ if self.ws and not self.ws.closed:
1058
+ logger.debug(f"[CLIENT-DISCONNECT-WS] Closing WebSocket connection: client_id={self.client_id}")
1059
+ await self.ws.close()
1060
+ logger.info(f"[CLIENT-DISCONNECT-WS] WebSocket closed: client_id={self.client_id}")
1061
+
1062
+ if self.session:
1063
+ logger.debug(f"[CLIENT-DISCONNECT-SESSION] Closing aiohttp session: client_id={self.client_id}")
1064
+ await self.session.close()
1065
+ logger.info(f"[CLIENT-DISCONNECT-SESSION] Session closed: client_id={self.client_id}")
1066
+
1067
+ logger.info(f"[CLIENT-DISCONNECT-COMPLETE] Disconnect completed: client_id={self.client_id}")
1068
+
1069
+ async def run(self):
1070
+ """Run client (with automatic reconnection)"""
1071
+ self.running = True
1072
+ logger.info(
1073
+ f"[CLIENT-RUN-START] Starting client: client_id={self.client_id}, "
1074
+ f"server_url={self.server_url}, workspace_id={self.workspace_id}"
1075
+ )
1076
+
1077
+ # Connect to MCP services
1078
+ try:
1079
+ logger.info(f"[CLIENT-MCP-CONNECT] Connecting to MCP services: client_id={self.client_id}")
1080
+ mcp_count = await self.mcp_manager.connect_all()
1081
+ if mcp_count > 0:
1082
+ logger.info(
1083
+ f"[CLIENT-MCP-CONNECTED] Successfully connected to MCP services: count={mcp_count}, "
1084
+ f"client_id={self.client_id}"
1085
+ )
1086
+ else:
1087
+ logger.warning(f"[CLIENT-MCP-NONE] No MCP services configured or connected: client_id={self.client_id}")
1088
+ except Exception as e:
1089
+ logger.error(
1090
+ f"[CLIENT-MCP-ERROR] Error connecting to MCP services: error={e}, client_id={self.client_id}",
1091
+ exc_info=True,
1092
+ )
1093
+
1094
+ reconnect_count = 0
1095
+ while self.running:
1096
+ # Attempting to connect
1097
+ logger.info(
1098
+ f"[CLIENT-RECONNECT] Attempting to connect: attempt={reconnect_count + 1}, client_id={self.client_id}"
1099
+ )
1100
+
1101
+ if await self.connect():
1102
+ reconnect_count = 0 # Reset reconnection count
1103
+ try:
1104
+ # Listen for messages
1105
+ await self._listen()
1106
+ except Exception as e:
1107
+ logger.error(
1108
+ f"[CLIENT-RUN-ERROR] Error during client run: error={e}, client_id={self.client_id}",
1109
+ exc_info=True,
1110
+ )
1111
+ finally:
1112
+ # Disconnect
1113
+ await self.disconnect()
1114
+ else:
1115
+ reconnect_count += 1
1116
+ logger.warning(
1117
+ f"[CLIENT-CONNECT-FAILED] Failed to establish connection: attempts={reconnect_count}, "
1118
+ f"client_id={self.client_id}"
1119
+ )
1120
+
1121
+ # If still running, wait then reconnect
1122
+ if self.running:
1123
+ logger.info(
1124
+ f"[CLIENT-RECONNECT-WAIT] Waiting for reconnection: delay={self.reconnect_interval}s, "
1125
+ f"client_id={self.client_id}"
1126
+ )
1127
+ await asyncio.sleep(self.reconnect_interval)
1128
+
1129
+ async def stop(self):
1130
+ """Stop client"""
1131
+ logger.info(f"[CLIENT-STOP-START] Stopping client: client_id={self.client_id}")
1132
+
1133
+ self.running = False
1134
+ await self.disconnect()
1135
+
1136
+ # Disconnect from MCP services
1137
+ try:
1138
+ logger.info(f"[CLIENT-MCP-DISCONNECT] Disconnecting from MCP services: client_id={self.client_id}")
1139
+ await self.mcp_manager.disconnect_all()
1140
+ logger.info(f"[CLIENT-MCP-DISCONNECTED] MCP services disconnected: client_id={self.client_id}")
1141
+ except Exception as e:
1142
+ logger.error(
1143
+ f"[CLIENT-MCP-DISCONNECT-ERROR] Error disconnecting from MCP services: error={e}, "
1144
+ f"client_id={self.client_id}",
1145
+ exc_info=True,
1146
+ )
1147
+
1148
+ logger.info(f"[CLIENT-STOP-COMPLETE] Client stopped: client_id={self.client_id}")