indent 0.1.22__py3-none-any.whl → 0.1.23__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.
Potentially problematic release.
This version of indent might be problematic. Click here for more details.
- exponent/__init__.py +2 -2
- exponent/core/remote_execution/cli_rpc_types.py +117 -0
- exponent/core/remote_execution/client.py +219 -32
- exponent/core/remote_execution/code_execution.py +26 -7
- exponent/core/remote_execution/languages/shell_streaming.py +2 -4
- exponent/core/remote_execution/terminal_session.py +429 -0
- exponent/core/remote_execution/terminal_types.py +29 -0
- exponent/core/remote_execution/tool_execution.py +4 -4
- exponent/core/remote_execution/truncation.py +1 -1
- {indent-0.1.22.dist-info → indent-0.1.23.dist-info}/METADATA +1 -1
- {indent-0.1.22.dist-info → indent-0.1.23.dist-info}/RECORD +13 -11
- {indent-0.1.22.dist-info → indent-0.1.23.dist-info}/WHEEL +0 -0
- {indent-0.1.22.dist-info → indent-0.1.23.dist-info}/entry_points.txt +0 -0
exponent/__init__.py
CHANGED
|
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
|
|
|
28
28
|
commit_id: COMMIT_ID
|
|
29
29
|
__commit_id__: COMMIT_ID
|
|
30
30
|
|
|
31
|
-
__version__ = version = '0.1.
|
|
32
|
-
__version_tuple__ = version_tuple = (0, 1,
|
|
31
|
+
__version__ = version = '0.1.23'
|
|
32
|
+
__version_tuple__ = version_tuple = (0, 1, 23)
|
|
33
33
|
|
|
34
34
|
__commit_id__ = commit_id = None
|
|
@@ -304,6 +304,77 @@ class GenerateUploadUrlResponse(msgspec.Struct, tag="generate_upload_url"):
|
|
|
304
304
|
s3_uri: str
|
|
305
305
|
|
|
306
306
|
|
|
307
|
+
# Terminal session management
|
|
308
|
+
class StartTerminalRequest(msgspec.Struct, tag="start_terminal"):
|
|
309
|
+
"""Start a new terminal session with PTY"""
|
|
310
|
+
|
|
311
|
+
session_id: str
|
|
312
|
+
command: list[str] | None = None # None = default shell, or specific command
|
|
313
|
+
cols: int = 80
|
|
314
|
+
rows: int = 24
|
|
315
|
+
env: dict[str, str] | None = None # Additional environment variables
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
class StartTerminalResponse(msgspec.Struct, tag="start_terminal"):
|
|
319
|
+
"""Response after starting terminal"""
|
|
320
|
+
|
|
321
|
+
session_id: str
|
|
322
|
+
success: bool
|
|
323
|
+
error_message: str | None = None
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
# Terminal input (user typing)
|
|
327
|
+
class TerminalInputRequest(msgspec.Struct, tag="terminal_input"):
|
|
328
|
+
"""Send user input to terminal"""
|
|
329
|
+
|
|
330
|
+
session_id: str
|
|
331
|
+
data: str # Raw input data from user
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
class TerminalInputResponse(msgspec.Struct, tag="terminal_input"):
|
|
335
|
+
"""Acknowledge input received"""
|
|
336
|
+
|
|
337
|
+
session_id: str
|
|
338
|
+
success: bool
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
# Terminal resize
|
|
342
|
+
class TerminalResizeRequest(msgspec.Struct, tag="terminal_resize"):
|
|
343
|
+
"""Resize terminal dimensions"""
|
|
344
|
+
|
|
345
|
+
session_id: str
|
|
346
|
+
cols: int
|
|
347
|
+
rows: int
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
class TerminalResizeResponse(msgspec.Struct, tag="terminal_resize"):
|
|
351
|
+
"""Acknowledge resize"""
|
|
352
|
+
|
|
353
|
+
session_id: str
|
|
354
|
+
success: bool
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
# Terminal stop
|
|
358
|
+
class StopTerminalRequest(msgspec.Struct, tag="stop_terminal"):
|
|
359
|
+
"""Stop a terminal session"""
|
|
360
|
+
|
|
361
|
+
session_id: str
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
class StopTerminalResponse(msgspec.Struct, tag="stop_terminal"):
|
|
365
|
+
"""Acknowledge terminal stopped"""
|
|
366
|
+
|
|
367
|
+
session_id: str
|
|
368
|
+
success: bool
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
class StreamingCodeExecutionRequest(msgspec.Struct, tag="streaming_code_execution"):
|
|
372
|
+
correlation_id: str
|
|
373
|
+
language: str # "python" or "shell"
|
|
374
|
+
content: str
|
|
375
|
+
timeout: int
|
|
376
|
+
|
|
377
|
+
|
|
307
378
|
class CliRpcRequest(msgspec.Struct):
|
|
308
379
|
request_id: str
|
|
309
380
|
request: (
|
|
@@ -315,6 +386,11 @@ class CliRpcRequest(msgspec.Struct):
|
|
|
315
386
|
| SwitchCLIChatRequest
|
|
316
387
|
| KeepAliveCliChatRequest
|
|
317
388
|
| GenerateUploadUrlRequest
|
|
389
|
+
| StartTerminalRequest
|
|
390
|
+
| TerminalInputRequest
|
|
391
|
+
| TerminalResizeRequest
|
|
392
|
+
| StopTerminalRequest
|
|
393
|
+
| StreamingCodeExecutionRequest
|
|
318
394
|
)
|
|
319
395
|
|
|
320
396
|
|
|
@@ -326,6 +402,40 @@ class ErrorResponse(msgspec.Struct, tag="error"):
|
|
|
326
402
|
error_message: str
|
|
327
403
|
|
|
328
404
|
|
|
405
|
+
class StreamingCodeExecutionResponseChunk(
|
|
406
|
+
msgspec.Struct, tag="streaming_code_execution_chunk"
|
|
407
|
+
):
|
|
408
|
+
correlation_id: str
|
|
409
|
+
content: str
|
|
410
|
+
truncated: bool = False
|
|
411
|
+
|
|
412
|
+
def add(
|
|
413
|
+
self, new_chunk: "StreamingCodeExecutionResponseChunk"
|
|
414
|
+
) -> "StreamingCodeExecutionResponseChunk":
|
|
415
|
+
"""Aggregates content of this and a new chunk."""
|
|
416
|
+
assert self.correlation_id == new_chunk.correlation_id
|
|
417
|
+
return StreamingCodeExecutionResponseChunk(
|
|
418
|
+
correlation_id=self.correlation_id,
|
|
419
|
+
content=self.content + new_chunk.content,
|
|
420
|
+
truncated=self.truncated or new_chunk.truncated,
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
class StreamingCodeExecutionResponse(msgspec.Struct, tag="streaming_code_execution"):
|
|
425
|
+
correlation_id: str
|
|
426
|
+
content: str
|
|
427
|
+
truncated: bool = False
|
|
428
|
+
# Only present for shell code execution
|
|
429
|
+
cancelled_for_timeout: bool = False
|
|
430
|
+
exit_code: int | None = None
|
|
431
|
+
halted: bool = False
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
class StreamingErrorResponse(msgspec.Struct, tag="streaming_error"):
|
|
435
|
+
correlation_id: str
|
|
436
|
+
error_message: str
|
|
437
|
+
|
|
438
|
+
|
|
329
439
|
class CliRpcResponse(msgspec.Struct):
|
|
330
440
|
request_id: str
|
|
331
441
|
response: (
|
|
@@ -339,4 +449,11 @@ class CliRpcResponse(msgspec.Struct):
|
|
|
339
449
|
| SwitchCLIChatResponse
|
|
340
450
|
| KeepAliveCliChatResponse
|
|
341
451
|
| GenerateUploadUrlResponse
|
|
452
|
+
| StartTerminalResponse
|
|
453
|
+
| TerminalInputResponse
|
|
454
|
+
| TerminalResizeResponse
|
|
455
|
+
| StopTerminalResponse
|
|
456
|
+
| StreamingCodeExecutionResponseChunk
|
|
457
|
+
| StreamingCodeExecutionResponse
|
|
458
|
+
| StreamingErrorResponse
|
|
342
459
|
)
|
|
@@ -38,8 +38,16 @@ from exponent.core.remote_execution.cli_rpc_types import (
|
|
|
38
38
|
HttpRequest,
|
|
39
39
|
KeepAliveCliChatRequest,
|
|
40
40
|
KeepAliveCliChatResponse,
|
|
41
|
+
StartTerminalRequest,
|
|
42
|
+
StartTerminalResponse,
|
|
43
|
+
StopTerminalRequest,
|
|
44
|
+
StopTerminalResponse,
|
|
41
45
|
SwitchCLIChatRequest,
|
|
42
46
|
SwitchCLIChatResponse,
|
|
47
|
+
TerminalInputRequest,
|
|
48
|
+
TerminalInputResponse,
|
|
49
|
+
TerminalResizeRequest,
|
|
50
|
+
TerminalResizeResponse,
|
|
43
51
|
TerminateRequest,
|
|
44
52
|
TerminateResponse,
|
|
45
53
|
ToolExecutionRequest,
|
|
@@ -56,6 +64,8 @@ from exponent.core.remote_execution.session import (
|
|
|
56
64
|
get_session,
|
|
57
65
|
send_exception_log,
|
|
58
66
|
)
|
|
67
|
+
from exponent.core.remote_execution.terminal_session import TerminalSessionManager
|
|
68
|
+
from exponent.core.remote_execution.terminal_types import TerminalMessage
|
|
59
69
|
from exponent.core.remote_execution.tool_execution import (
|
|
60
70
|
execute_bash_tool,
|
|
61
71
|
execute_tool,
|
|
@@ -66,9 +76,7 @@ from exponent.core.remote_execution.types import (
|
|
|
66
76
|
CLIConnectedState,
|
|
67
77
|
CreateChatResponse,
|
|
68
78
|
HeartbeatInfo,
|
|
69
|
-
RemoteExecutionResponseType,
|
|
70
79
|
RunWorkflowRequest,
|
|
71
|
-
StreamingCodeExecutionRequest,
|
|
72
80
|
WorkflowInput,
|
|
73
81
|
WorkflowTriggerRequest,
|
|
74
82
|
WorkflowTriggerResponse,
|
|
@@ -183,11 +191,12 @@ class RemoteExecutionClient:
|
|
|
183
191
|
# Handle cancellation gracefully
|
|
184
192
|
return None
|
|
185
193
|
|
|
186
|
-
async def _handle_websocket_message(
|
|
194
|
+
async def _handle_websocket_message( # noqa: PLR0911, PLR0915
|
|
187
195
|
self,
|
|
188
196
|
msg: str,
|
|
189
197
|
websocket: ClientConnection,
|
|
190
198
|
requests: asyncio.Queue[CliRpcRequest],
|
|
199
|
+
terminal_session_manager: TerminalSessionManager,
|
|
191
200
|
) -> REMOTE_EXECUTION_CLIENT_EXIT_INFO | None:
|
|
192
201
|
"""Handle an incoming websocket message.
|
|
193
202
|
Returns None to continue processing, or a REMOTE_EXECUTION_CLIENT_EXIT_INFO to exit."""
|
|
@@ -215,7 +224,7 @@ class RemoteExecutionClient:
|
|
|
215
224
|
data = json.dumps(msg_data["data"])
|
|
216
225
|
try:
|
|
217
226
|
request = msgspec.json.decode(data, type=CliRpcRequest)
|
|
218
|
-
except msgspec.DecodeError as e:
|
|
227
|
+
except (msgspec.DecodeError, msgspec.ValidationError) as e:
|
|
219
228
|
# Try and decode to get request_id if possible
|
|
220
229
|
request = msgspec.json.decode(data)
|
|
221
230
|
if isinstance(request, dict) and "request_id" in request:
|
|
@@ -317,6 +326,101 @@ class RemoteExecutionClient:
|
|
|
317
326
|
)
|
|
318
327
|
)
|
|
319
328
|
return None
|
|
329
|
+
elif isinstance(request.request, StartTerminalRequest):
|
|
330
|
+
# Start a new terminal session
|
|
331
|
+
session_id = await terminal_session_manager.start_session(
|
|
332
|
+
websocket=websocket,
|
|
333
|
+
session_id=request.request.session_id,
|
|
334
|
+
command=request.request.command,
|
|
335
|
+
cols=request.request.cols,
|
|
336
|
+
rows=request.request.rows,
|
|
337
|
+
env=request.request.env,
|
|
338
|
+
)
|
|
339
|
+
await websocket.send(
|
|
340
|
+
json.dumps(
|
|
341
|
+
{
|
|
342
|
+
"type": "result",
|
|
343
|
+
"data": msgspec.to_builtins(
|
|
344
|
+
CliRpcResponse(
|
|
345
|
+
request_id=request.request_id,
|
|
346
|
+
response=StartTerminalResponse(
|
|
347
|
+
session_id=session_id, success=True
|
|
348
|
+
),
|
|
349
|
+
)
|
|
350
|
+
),
|
|
351
|
+
}
|
|
352
|
+
)
|
|
353
|
+
)
|
|
354
|
+
return None
|
|
355
|
+
elif isinstance(request.request, TerminalInputRequest):
|
|
356
|
+
# Send input to terminal session
|
|
357
|
+
success = await terminal_session_manager.send_input(
|
|
358
|
+
session_id=request.request.session_id,
|
|
359
|
+
data=request.request.data,
|
|
360
|
+
)
|
|
361
|
+
await websocket.send(
|
|
362
|
+
json.dumps(
|
|
363
|
+
{
|
|
364
|
+
"type": "result",
|
|
365
|
+
"data": msgspec.to_builtins(
|
|
366
|
+
CliRpcResponse(
|
|
367
|
+
request_id=request.request_id,
|
|
368
|
+
response=TerminalInputResponse(
|
|
369
|
+
session_id=request.request.session_id,
|
|
370
|
+
success=success,
|
|
371
|
+
),
|
|
372
|
+
)
|
|
373
|
+
),
|
|
374
|
+
}
|
|
375
|
+
)
|
|
376
|
+
)
|
|
377
|
+
return None
|
|
378
|
+
elif isinstance(request.request, TerminalResizeRequest):
|
|
379
|
+
# Resize terminal session
|
|
380
|
+
success = await terminal_session_manager.resize_terminal(
|
|
381
|
+
session_id=request.request.session_id,
|
|
382
|
+
rows=request.request.rows,
|
|
383
|
+
cols=request.request.cols,
|
|
384
|
+
)
|
|
385
|
+
await websocket.send(
|
|
386
|
+
json.dumps(
|
|
387
|
+
{
|
|
388
|
+
"type": "result",
|
|
389
|
+
"data": msgspec.to_builtins(
|
|
390
|
+
CliRpcResponse(
|
|
391
|
+
request_id=request.request_id,
|
|
392
|
+
response=TerminalResizeResponse(
|
|
393
|
+
session_id=request.request.session_id,
|
|
394
|
+
success=success,
|
|
395
|
+
),
|
|
396
|
+
)
|
|
397
|
+
),
|
|
398
|
+
}
|
|
399
|
+
)
|
|
400
|
+
)
|
|
401
|
+
return None
|
|
402
|
+
elif isinstance(request.request, StopTerminalRequest):
|
|
403
|
+
# Stop terminal session
|
|
404
|
+
success = await terminal_session_manager.stop_session(
|
|
405
|
+
session_id=request.request.session_id
|
|
406
|
+
)
|
|
407
|
+
await websocket.send(
|
|
408
|
+
json.dumps(
|
|
409
|
+
{
|
|
410
|
+
"type": "result",
|
|
411
|
+
"data": msgspec.to_builtins(
|
|
412
|
+
CliRpcResponse(
|
|
413
|
+
request_id=request.request_id,
|
|
414
|
+
response=StopTerminalResponse(
|
|
415
|
+
session_id=request.request.session_id,
|
|
416
|
+
success=success,
|
|
417
|
+
),
|
|
418
|
+
)
|
|
419
|
+
),
|
|
420
|
+
}
|
|
421
|
+
)
|
|
422
|
+
)
|
|
423
|
+
return None
|
|
320
424
|
else:
|
|
321
425
|
if isinstance(request.request, ToolExecutionRequest) and isinstance(
|
|
322
426
|
request.request.tool_input, BashToolInput
|
|
@@ -363,21 +467,31 @@ class RemoteExecutionClient:
|
|
|
363
467
|
request = await requests.get()
|
|
364
468
|
|
|
365
469
|
try:
|
|
366
|
-
# if
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
470
|
+
# Check if this is a streaming request
|
|
471
|
+
from exponent.core.remote_execution.cli_rpc_types import (
|
|
472
|
+
StreamingCodeExecutionRequest,
|
|
473
|
+
)
|
|
474
|
+
|
|
475
|
+
if isinstance(request.request, StreamingCodeExecutionRequest):
|
|
476
|
+
async for streaming_response in self.handle_streaming_request(
|
|
477
|
+
request.request
|
|
478
|
+
):
|
|
479
|
+
async with results_lock:
|
|
480
|
+
await results.put(
|
|
481
|
+
CliRpcResponse(
|
|
482
|
+
request_id=request.request_id,
|
|
483
|
+
response=streaming_response,
|
|
484
|
+
)
|
|
485
|
+
)
|
|
486
|
+
else:
|
|
487
|
+
# Note that we don't want to hold the lock here
|
|
488
|
+
# because we want other executors to be able to
|
|
489
|
+
# grab requests while we're handling a request.
|
|
490
|
+
logger.info(f"Handling request {request}")
|
|
491
|
+
response = await self.handle_request(request)
|
|
492
|
+
async with results_lock:
|
|
493
|
+
logger.info(f"Putting response {response}")
|
|
494
|
+
await results.put(response)
|
|
381
495
|
except Exception as e:
|
|
382
496
|
logger.info(f"Error handling request {request}:\n\n{e}")
|
|
383
497
|
try:
|
|
@@ -385,15 +499,32 @@ class RemoteExecutionClient:
|
|
|
385
499
|
except Exception:
|
|
386
500
|
pass
|
|
387
501
|
async with results_lock:
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
response=ErrorResponse(
|
|
392
|
-
error_message=str(e),
|
|
393
|
-
),
|
|
394
|
-
)
|
|
502
|
+
from exponent.core.remote_execution.cli_rpc_types import (
|
|
503
|
+
StreamingCodeExecutionRequest,
|
|
504
|
+
StreamingErrorResponse,
|
|
395
505
|
)
|
|
396
506
|
|
|
507
|
+
if isinstance(request.request, StreamingCodeExecutionRequest):
|
|
508
|
+
# For streaming requests, send a streaming error response
|
|
509
|
+
await results.put(
|
|
510
|
+
CliRpcResponse(
|
|
511
|
+
request_id=request.request_id,
|
|
512
|
+
response=StreamingErrorResponse(
|
|
513
|
+
correlation_id=request.request.correlation_id,
|
|
514
|
+
error_message=str(e),
|
|
515
|
+
),
|
|
516
|
+
)
|
|
517
|
+
)
|
|
518
|
+
else:
|
|
519
|
+
await results.put(
|
|
520
|
+
CliRpcResponse(
|
|
521
|
+
request_id=request.request_id,
|
|
522
|
+
response=ErrorResponse(
|
|
523
|
+
error_message=str(e),
|
|
524
|
+
),
|
|
525
|
+
)
|
|
526
|
+
)
|
|
527
|
+
|
|
397
528
|
beat_task = asyncio.create_task(beat())
|
|
398
529
|
# Three parallel executors to handle requests
|
|
399
530
|
|
|
@@ -411,6 +542,8 @@ class RemoteExecutionClient:
|
|
|
411
542
|
beats: asyncio.Queue[HeartbeatInfo],
|
|
412
543
|
requests: asyncio.Queue[CliRpcRequest],
|
|
413
544
|
results: asyncio.Queue[CliRpcResponse],
|
|
545
|
+
terminal_output_queue: asyncio.Queue[TerminalMessage],
|
|
546
|
+
terminal_session_manager: TerminalSessionManager,
|
|
414
547
|
) -> REMOTE_EXECUTION_CLIENT_EXIT_INFO:
|
|
415
548
|
"""Process messages from the websocket connection."""
|
|
416
549
|
pending: set[asyncio.Task[object]] = set()
|
|
@@ -418,7 +551,8 @@ class RemoteExecutionClient:
|
|
|
418
551
|
recv = asyncio.create_task(websocket.recv())
|
|
419
552
|
get_beat = asyncio.create_task(beats.get())
|
|
420
553
|
get_result = asyncio.create_task(results.get())
|
|
421
|
-
|
|
554
|
+
get_terminal_output = asyncio.create_task(terminal_output_queue.get())
|
|
555
|
+
pending = {recv, get_beat, get_result, get_terminal_output}
|
|
422
556
|
|
|
423
557
|
while True:
|
|
424
558
|
done, pending = await asyncio.wait(
|
|
@@ -428,7 +562,7 @@ class RemoteExecutionClient:
|
|
|
428
562
|
if recv in done:
|
|
429
563
|
msg = str(recv.result())
|
|
430
564
|
exit_info = await self._handle_websocket_message(
|
|
431
|
-
msg, websocket, requests
|
|
565
|
+
msg, websocket, requests, terminal_session_manager
|
|
432
566
|
)
|
|
433
567
|
if exit_info is not None:
|
|
434
568
|
return exit_info
|
|
@@ -447,12 +581,24 @@ class RemoteExecutionClient:
|
|
|
447
581
|
|
|
448
582
|
if get_result in done:
|
|
449
583
|
response = get_result.result()
|
|
584
|
+
# All responses are now CliRpcResponse with msgspec
|
|
450
585
|
data = msgspec.to_builtins(response)
|
|
451
586
|
msg = json.dumps({"type": "result", "data": data})
|
|
452
587
|
await websocket.send(msg)
|
|
453
588
|
|
|
454
589
|
get_result = asyncio.create_task(results.get())
|
|
455
590
|
pending.add(get_result)
|
|
591
|
+
|
|
592
|
+
if get_terminal_output in done:
|
|
593
|
+
terminal_message = get_terminal_output.result()
|
|
594
|
+
data = msgspec.to_builtins(terminal_message)
|
|
595
|
+
msg = json.dumps({"type": "terminal_message", "data": data})
|
|
596
|
+
await websocket.send(msg)
|
|
597
|
+
|
|
598
|
+
get_terminal_output = asyncio.create_task(
|
|
599
|
+
terminal_output_queue.get()
|
|
600
|
+
)
|
|
601
|
+
pending.add(get_terminal_output)
|
|
456
602
|
finally:
|
|
457
603
|
for task in pending:
|
|
458
604
|
task.cancel()
|
|
@@ -466,6 +612,8 @@ class RemoteExecutionClient:
|
|
|
466
612
|
beats: asyncio.Queue[HeartbeatInfo],
|
|
467
613
|
requests: asyncio.Queue[CliRpcRequest],
|
|
468
614
|
results: asyncio.Queue[CliRpcResponse],
|
|
615
|
+
terminal_output_queue: asyncio.Queue[TerminalMessage],
|
|
616
|
+
terminal_session_manager: TerminalSessionManager,
|
|
469
617
|
) -> REMOTE_EXECUTION_CLIENT_EXIT_INFO | None:
|
|
470
618
|
"""Handle a single websocket connection.
|
|
471
619
|
Returns None to continue with reconnection attempts, or an exit info to terminate."""
|
|
@@ -476,7 +624,12 @@ class RemoteExecutionClient:
|
|
|
476
624
|
|
|
477
625
|
try:
|
|
478
626
|
return await self._process_websocket_messages(
|
|
479
|
-
websocket,
|
|
627
|
+
websocket,
|
|
628
|
+
beats,
|
|
629
|
+
requests,
|
|
630
|
+
results,
|
|
631
|
+
terminal_output_queue,
|
|
632
|
+
terminal_session_manager,
|
|
480
633
|
)
|
|
481
634
|
except websockets.exceptions.ConnectionClosed as e:
|
|
482
635
|
if e.rcvd is not None:
|
|
@@ -517,6 +670,10 @@ class RemoteExecutionClient:
|
|
|
517
670
|
beats: asyncio.Queue[HeartbeatInfo] = asyncio.Queue()
|
|
518
671
|
requests: asyncio.Queue[CliRpcRequest] = asyncio.Queue()
|
|
519
672
|
results: asyncio.Queue[CliRpcResponse] = asyncio.Queue()
|
|
673
|
+
terminal_output_queue: asyncio.Queue[TerminalMessage] = asyncio.Queue()
|
|
674
|
+
|
|
675
|
+
# Create terminal session manager ONCE - persist across reconnections
|
|
676
|
+
terminal_session_manager = TerminalSessionManager(terminal_output_queue)
|
|
520
677
|
|
|
521
678
|
# Create tasks ONCE - persist across reconnections
|
|
522
679
|
executors = await self._setup_tasks(beats, requests, results)
|
|
@@ -529,7 +686,13 @@ class RemoteExecutionClient:
|
|
|
529
686
|
[
|
|
530
687
|
asyncio.create_task(
|
|
531
688
|
self._handle_websocket_connection(
|
|
532
|
-
websocket,
|
|
689
|
+
websocket,
|
|
690
|
+
connection_tracker,
|
|
691
|
+
beats,
|
|
692
|
+
requests,
|
|
693
|
+
results,
|
|
694
|
+
terminal_output_queue,
|
|
695
|
+
terminal_session_manager,
|
|
533
696
|
)
|
|
534
697
|
),
|
|
535
698
|
asyncio.create_task(self._timeout_monitor(timeout_seconds)),
|
|
@@ -554,6 +717,9 @@ class RemoteExecutionClient:
|
|
|
554
717
|
error_message="Could not establish websocket connection"
|
|
555
718
|
)
|
|
556
719
|
finally:
|
|
720
|
+
# Stop all terminal sessions to clean up PTY processes
|
|
721
|
+
await terminal_session_manager.stop_all_sessions()
|
|
722
|
+
|
|
557
723
|
# Cancel all background tasks when exiting
|
|
558
724
|
for task in executors:
|
|
559
725
|
task.cancel()
|
|
@@ -733,6 +899,22 @@ class RemoteExecutionClient:
|
|
|
733
899
|
raise ValueError(
|
|
734
900
|
"KeepAliveCliChatRequest should not be handled by handle_request"
|
|
735
901
|
)
|
|
902
|
+
elif isinstance(request.request, StartTerminalRequest):
|
|
903
|
+
raise ValueError(
|
|
904
|
+
"StartTerminalRequest should not be handled by handle_request"
|
|
905
|
+
)
|
|
906
|
+
elif isinstance(request.request, TerminalInputRequest):
|
|
907
|
+
raise ValueError(
|
|
908
|
+
"TerminalInputRequest should not be handled by handle_request"
|
|
909
|
+
)
|
|
910
|
+
elif isinstance(request.request, TerminalResizeRequest):
|
|
911
|
+
raise ValueError(
|
|
912
|
+
"TerminalResizeRequest should not be handled by handle_request"
|
|
913
|
+
)
|
|
914
|
+
elif isinstance(request.request, StopTerminalRequest):
|
|
915
|
+
raise ValueError(
|
|
916
|
+
"StopTerminalRequest should not be handled by handle_request"
|
|
917
|
+
)
|
|
736
918
|
|
|
737
919
|
raise ValueError(f"Unhandled request type: {type(request)}")
|
|
738
920
|
|
|
@@ -754,8 +936,13 @@ class RemoteExecutionClient:
|
|
|
754
936
|
await self.clear_halt_state(request.request_id)
|
|
755
937
|
|
|
756
938
|
async def handle_streaming_request(
|
|
757
|
-
self,
|
|
758
|
-
|
|
939
|
+
self,
|
|
940
|
+
request: Any,
|
|
941
|
+
) -> AsyncGenerator[Any, None]:
|
|
942
|
+
from exponent.core.remote_execution.cli_rpc_types import (
|
|
943
|
+
StreamingCodeExecutionRequest,
|
|
944
|
+
)
|
|
945
|
+
|
|
759
946
|
if not isinstance(request, StreamingCodeExecutionRequest):
|
|
760
947
|
assert False, f"{type(request)} should be sent to handle_streaming_request"
|
|
761
948
|
async for output in execute_code_streaming(
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
from collections.abc import AsyncGenerator, Callable
|
|
2
2
|
|
|
3
|
+
from exponent.core.remote_execution.cli_rpc_types import (
|
|
4
|
+
StreamingCodeExecutionRequest,
|
|
5
|
+
StreamingCodeExecutionResponse,
|
|
6
|
+
StreamingCodeExecutionResponseChunk,
|
|
7
|
+
)
|
|
3
8
|
from exponent.core.remote_execution.languages.python_execution import (
|
|
4
9
|
execute_python_streaming,
|
|
5
10
|
)
|
|
@@ -8,13 +13,9 @@ from exponent.core.remote_execution.languages.shell_streaming import (
|
|
|
8
13
|
)
|
|
9
14
|
from exponent.core.remote_execution.languages.types import StreamedOutputPiece
|
|
10
15
|
from exponent.core.remote_execution.session import RemoteExecutionClientSession
|
|
11
|
-
from exponent.core.remote_execution.types import (
|
|
12
|
-
StreamingCodeExecutionRequest,
|
|
13
|
-
StreamingCodeExecutionResponse,
|
|
14
|
-
StreamingCodeExecutionResponseChunk,
|
|
15
|
-
)
|
|
16
16
|
|
|
17
17
|
EMPTY_OUTPUT_STRING = "(No output)"
|
|
18
|
+
MAX_OUTPUT_LENGTH = 50000 # Maximum characters to keep in final output
|
|
18
19
|
|
|
19
20
|
|
|
20
21
|
async def execute_code_streaming(
|
|
@@ -34,9 +35,18 @@ async def execute_code_streaming(
|
|
|
34
35
|
content=output.content, correlation_id=request.correlation_id
|
|
35
36
|
)
|
|
36
37
|
else:
|
|
38
|
+
final_output = output.output or EMPTY_OUTPUT_STRING
|
|
39
|
+
truncated = len(final_output) > MAX_OUTPUT_LENGTH
|
|
40
|
+
if truncated:
|
|
41
|
+
final_output = final_output[-MAX_OUTPUT_LENGTH:]
|
|
42
|
+
final_output = (
|
|
43
|
+
f"[Truncated to last {MAX_OUTPUT_LENGTH} characters]\n\n"
|
|
44
|
+
+ final_output
|
|
45
|
+
)
|
|
37
46
|
yield StreamingCodeExecutionResponse(
|
|
38
47
|
correlation_id=request.correlation_id,
|
|
39
|
-
content=
|
|
48
|
+
content=final_output,
|
|
49
|
+
truncated=truncated,
|
|
40
50
|
halted=output.halted,
|
|
41
51
|
)
|
|
42
52
|
|
|
@@ -49,9 +59,18 @@ async def execute_code_streaming(
|
|
|
49
59
|
content=shell_output.content, correlation_id=request.correlation_id
|
|
50
60
|
)
|
|
51
61
|
else:
|
|
62
|
+
final_output = shell_output.output or EMPTY_OUTPUT_STRING
|
|
63
|
+
truncated = len(final_output) > MAX_OUTPUT_LENGTH
|
|
64
|
+
if truncated:
|
|
65
|
+
final_output = final_output[-MAX_OUTPUT_LENGTH:]
|
|
66
|
+
final_output = (
|
|
67
|
+
f"[Truncated to last {MAX_OUTPUT_LENGTH} characters]\n\n"
|
|
68
|
+
+ final_output
|
|
69
|
+
)
|
|
52
70
|
yield StreamingCodeExecutionResponse(
|
|
53
71
|
correlation_id=request.correlation_id,
|
|
54
|
-
content=
|
|
72
|
+
content=final_output,
|
|
73
|
+
truncated=truncated,
|
|
55
74
|
halted=shell_output.halted,
|
|
56
75
|
exit_code=shell_output.exit_code,
|
|
57
76
|
cancelled_for_timeout=shell_output.cancelled_for_timeout,
|
|
@@ -50,7 +50,7 @@ async def read_stream(
|
|
|
50
50
|
|
|
51
51
|
while True:
|
|
52
52
|
try:
|
|
53
|
-
data = await stream.read(
|
|
53
|
+
data = await stream.read(50_000)
|
|
54
54
|
if not data:
|
|
55
55
|
break
|
|
56
56
|
chunk = data.decode(encoding=encoding, errors="replace")
|
|
@@ -182,10 +182,8 @@ async def execute_shell_streaming( # noqa: PLR0915
|
|
|
182
182
|
piece = await task
|
|
183
183
|
yield piece
|
|
184
184
|
|
|
185
|
-
if process.returncode is not None:
|
|
186
|
-
break
|
|
187
|
-
|
|
188
185
|
# Schedule next read from the same stream
|
|
186
|
+
# Don't check process.returncode here - we need to drain all buffered output
|
|
189
187
|
if task is stdout_task and not process.stdout.at_eof():
|
|
190
188
|
stdout_task = asyncio.create_task(stdout_gen.__anext__())
|
|
191
189
|
pending.add(stdout_task)
|
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import fcntl
|
|
3
|
+
import os
|
|
4
|
+
import pty
|
|
5
|
+
import signal
|
|
6
|
+
import struct
|
|
7
|
+
import sys
|
|
8
|
+
import termios
|
|
9
|
+
import time
|
|
10
|
+
import traceback
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
|
|
13
|
+
import structlog
|
|
14
|
+
|
|
15
|
+
from exponent.core.remote_execution.terminal_types import (
|
|
16
|
+
TerminalMessage,
|
|
17
|
+
TerminalOutput,
|
|
18
|
+
TerminalResetSessions,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
logger = structlog.get_logger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class TerminalSession:
|
|
25
|
+
"""
|
|
26
|
+
Manages a PTY session for terminal emulation.
|
|
27
|
+
Runs on the CLI machine and streams output back to server.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(
|
|
31
|
+
self,
|
|
32
|
+
session_id: str,
|
|
33
|
+
output_callback: Callable[[str], None],
|
|
34
|
+
cols: int = 80,
|
|
35
|
+
rows: int = 24,
|
|
36
|
+
):
|
|
37
|
+
self.session_id = session_id
|
|
38
|
+
self.output_callback = output_callback # Called with terminal output
|
|
39
|
+
self.cols = cols
|
|
40
|
+
self.rows = rows
|
|
41
|
+
self.master_fd: int | None = None
|
|
42
|
+
self.pid: int | None = None
|
|
43
|
+
self._running = False
|
|
44
|
+
self._read_task: asyncio.Task[None] | None = None
|
|
45
|
+
|
|
46
|
+
async def start(
|
|
47
|
+
self,
|
|
48
|
+
command: list[str] | None = None,
|
|
49
|
+
env: dict[str, str] | None = None,
|
|
50
|
+
) -> None:
|
|
51
|
+
"""Start the terminal session with PTY"""
|
|
52
|
+
if self._running:
|
|
53
|
+
raise RuntimeError(f"Terminal session {self.session_id} already running")
|
|
54
|
+
|
|
55
|
+
# Default to bash if no command specified
|
|
56
|
+
if command is None:
|
|
57
|
+
command = ["/bin/bash"]
|
|
58
|
+
|
|
59
|
+
# Spawn process with PTY
|
|
60
|
+
try:
|
|
61
|
+
self.pid, self.master_fd = pty.fork()
|
|
62
|
+
except OSError as e:
|
|
63
|
+
logger.error(
|
|
64
|
+
"Failed to fork PTY",
|
|
65
|
+
session_id=self.session_id,
|
|
66
|
+
error=str(e),
|
|
67
|
+
)
|
|
68
|
+
raise RuntimeError(f"Failed to fork PTY: {e}") from e
|
|
69
|
+
|
|
70
|
+
if self.pid == 0:
|
|
71
|
+
# Child process - execute command
|
|
72
|
+
try:
|
|
73
|
+
# Set up environment
|
|
74
|
+
if env:
|
|
75
|
+
for key, value in env.items():
|
|
76
|
+
os.environ[key] = value
|
|
77
|
+
|
|
78
|
+
# Set terminal environment
|
|
79
|
+
os.environ["TERM"] = "xterm-256color"
|
|
80
|
+
os.environ["COLORTERM"] = "truecolor"
|
|
81
|
+
|
|
82
|
+
# Execute command
|
|
83
|
+
os.execvp(command[0], command)
|
|
84
|
+
except Exception as e:
|
|
85
|
+
# If exec fails, log and exit child process
|
|
86
|
+
traceback.print_exc()
|
|
87
|
+
sys.stderr.write(f"Failed to execute command {command}: {e}\n")
|
|
88
|
+
sys.stderr.flush()
|
|
89
|
+
os._exit(1)
|
|
90
|
+
else:
|
|
91
|
+
# Parent process - set up non-blocking I/O
|
|
92
|
+
flags = fcntl.fcntl(self.master_fd, fcntl.F_GETFL)
|
|
93
|
+
fcntl.fcntl(self.master_fd, fcntl.F_SETFL, flags | os.O_NONBLOCK)
|
|
94
|
+
|
|
95
|
+
# Set initial size
|
|
96
|
+
self.resize(self.cols, self.rows)
|
|
97
|
+
|
|
98
|
+
# Start reading from PTY
|
|
99
|
+
self._running = True
|
|
100
|
+
self._read_task = asyncio.create_task(self._read_from_pty())
|
|
101
|
+
|
|
102
|
+
logger.info(
|
|
103
|
+
"Terminal session started",
|
|
104
|
+
session_id=self.session_id,
|
|
105
|
+
pid=self.pid,
|
|
106
|
+
command=command,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
async def _read_from_pty(self) -> None:
|
|
110
|
+
"""Continuously read from PTY using event loop's add_reader (non-blocking)"""
|
|
111
|
+
if self.master_fd is None:
|
|
112
|
+
return
|
|
113
|
+
|
|
114
|
+
loop = asyncio.get_event_loop()
|
|
115
|
+
read_queue: asyncio.Queue[bytes | None] = asyncio.Queue()
|
|
116
|
+
|
|
117
|
+
def read_callback() -> None:
|
|
118
|
+
"""Called by event loop when data is available on the FD"""
|
|
119
|
+
if self.master_fd is None:
|
|
120
|
+
return
|
|
121
|
+
try:
|
|
122
|
+
data = os.read(self.master_fd, 4096)
|
|
123
|
+
if data:
|
|
124
|
+
# Put data in queue to be processed by async task
|
|
125
|
+
read_queue.put_nowait(data)
|
|
126
|
+
else:
|
|
127
|
+
# EOF - PTY closed
|
|
128
|
+
read_queue.put_nowait(None)
|
|
129
|
+
except OSError as e:
|
|
130
|
+
if e.errno == 11: # EAGAIN - shouldn't happen with add_reader
|
|
131
|
+
pass
|
|
132
|
+
else:
|
|
133
|
+
# PTY closed or error
|
|
134
|
+
logger.info(
|
|
135
|
+
"PTY read error in callback",
|
|
136
|
+
session_id=self.session_id,
|
|
137
|
+
error=str(e),
|
|
138
|
+
errno=e.errno,
|
|
139
|
+
)
|
|
140
|
+
read_queue.put_nowait(None)
|
|
141
|
+
except Exception as e:
|
|
142
|
+
logger.error(
|
|
143
|
+
"Unexpected error in PTY read callback",
|
|
144
|
+
session_id=self.session_id,
|
|
145
|
+
error=str(e),
|
|
146
|
+
)
|
|
147
|
+
read_queue.put_nowait(None)
|
|
148
|
+
|
|
149
|
+
# Register the FD with the event loop
|
|
150
|
+
loop.add_reader(self.master_fd, read_callback)
|
|
151
|
+
|
|
152
|
+
try:
|
|
153
|
+
while self._running:
|
|
154
|
+
# Wait for data from the queue (non-blocking for event loop)
|
|
155
|
+
data = await read_queue.get()
|
|
156
|
+
|
|
157
|
+
if data is None:
|
|
158
|
+
# EOF or error
|
|
159
|
+
logger.info(
|
|
160
|
+
"PTY closed (EOF)",
|
|
161
|
+
session_id=self.session_id,
|
|
162
|
+
)
|
|
163
|
+
break
|
|
164
|
+
|
|
165
|
+
# Process the data
|
|
166
|
+
decoded = data.decode("utf-8", errors="replace")
|
|
167
|
+
self.output_callback(decoded)
|
|
168
|
+
finally:
|
|
169
|
+
# Unregister the FD from the event loop
|
|
170
|
+
loop.remove_reader(self.master_fd)
|
|
171
|
+
logger.info(
|
|
172
|
+
"PTY read loop exited",
|
|
173
|
+
session_id=self.session_id,
|
|
174
|
+
running=self._running,
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
async def write_input(self, data: str) -> None:
|
|
178
|
+
"""Write user input to PTY"""
|
|
179
|
+
if not self._running or self.master_fd is None:
|
|
180
|
+
raise RuntimeError(f"Terminal session {self.session_id} not running")
|
|
181
|
+
|
|
182
|
+
try:
|
|
183
|
+
os.write(self.master_fd, data.encode("utf-8"))
|
|
184
|
+
except OSError as e:
|
|
185
|
+
logger.error(
|
|
186
|
+
"Error writing to PTY",
|
|
187
|
+
session_id=self.session_id,
|
|
188
|
+
error=str(e),
|
|
189
|
+
)
|
|
190
|
+
raise
|
|
191
|
+
|
|
192
|
+
def resize(self, cols: int, rows: int) -> None:
|
|
193
|
+
"""Resize the PTY to match terminal dimensions"""
|
|
194
|
+
if self.master_fd is None:
|
|
195
|
+
return
|
|
196
|
+
|
|
197
|
+
self.cols = cols
|
|
198
|
+
self.rows = rows
|
|
199
|
+
|
|
200
|
+
try:
|
|
201
|
+
size = struct.pack("HHHH", rows, cols, 0, 0)
|
|
202
|
+
fcntl.ioctl(self.master_fd, termios.TIOCSWINSZ, size)
|
|
203
|
+
logger.debug(
|
|
204
|
+
"Terminal resized",
|
|
205
|
+
session_id=self.session_id,
|
|
206
|
+
cols=cols,
|
|
207
|
+
rows=rows,
|
|
208
|
+
)
|
|
209
|
+
except Exception as e:
|
|
210
|
+
logger.error(
|
|
211
|
+
"Error resizing PTY",
|
|
212
|
+
session_id=self.session_id,
|
|
213
|
+
error=str(e),
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
async def stop(self) -> tuple[bool, int | None]:
|
|
217
|
+
"""
|
|
218
|
+
Stop the terminal session and clean up resources.
|
|
219
|
+
Returns (success, exit_code)
|
|
220
|
+
"""
|
|
221
|
+
if not self._running:
|
|
222
|
+
return True, None
|
|
223
|
+
|
|
224
|
+
self._running = False
|
|
225
|
+
|
|
226
|
+
# Cancel read task
|
|
227
|
+
if self._read_task and not self._read_task.done():
|
|
228
|
+
self._read_task.cancel()
|
|
229
|
+
try:
|
|
230
|
+
await self._read_task
|
|
231
|
+
except asyncio.CancelledError:
|
|
232
|
+
pass
|
|
233
|
+
|
|
234
|
+
exit_code = None
|
|
235
|
+
|
|
236
|
+
# Close file descriptor
|
|
237
|
+
if self.master_fd is not None:
|
|
238
|
+
try:
|
|
239
|
+
os.close(self.master_fd)
|
|
240
|
+
except Exception as e:
|
|
241
|
+
logger.error(
|
|
242
|
+
"Error closing PTY fd",
|
|
243
|
+
session_id=self.session_id,
|
|
244
|
+
error=str(e),
|
|
245
|
+
)
|
|
246
|
+
self.master_fd = None
|
|
247
|
+
|
|
248
|
+
# Kill child process
|
|
249
|
+
if self.pid is not None:
|
|
250
|
+
try:
|
|
251
|
+
os.kill(self.pid, signal.SIGTERM)
|
|
252
|
+
# Wait for process to terminate (with timeout)
|
|
253
|
+
for _ in range(10): # Wait up to 1 second
|
|
254
|
+
try:
|
|
255
|
+
pid, status = os.waitpid(self.pid, os.WNOHANG)
|
|
256
|
+
if pid != 0:
|
|
257
|
+
exit_code = os.WEXITSTATUS(status)
|
|
258
|
+
break
|
|
259
|
+
except ChildProcessError:
|
|
260
|
+
break
|
|
261
|
+
await asyncio.sleep(0.1)
|
|
262
|
+
else:
|
|
263
|
+
# Force kill if still running
|
|
264
|
+
try:
|
|
265
|
+
os.kill(self.pid, signal.SIGKILL)
|
|
266
|
+
os.waitpid(self.pid, 0)
|
|
267
|
+
except Exception:
|
|
268
|
+
pass
|
|
269
|
+
except Exception as e:
|
|
270
|
+
logger.error(
|
|
271
|
+
"Error killing PTY process",
|
|
272
|
+
session_id=self.session_id,
|
|
273
|
+
error=str(e),
|
|
274
|
+
)
|
|
275
|
+
self.pid = None
|
|
276
|
+
|
|
277
|
+
logger.info(
|
|
278
|
+
"Terminal session stopped",
|
|
279
|
+
session_id=self.session_id,
|
|
280
|
+
exit_code=exit_code,
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
return True, exit_code
|
|
284
|
+
|
|
285
|
+
@property
|
|
286
|
+
def is_running(self) -> bool:
|
|
287
|
+
"""Check if terminal session is running"""
|
|
288
|
+
return self._running and self.master_fd is not None
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
class TerminalSessionManager:
|
|
292
|
+
"""Manages multiple terminal sessions"""
|
|
293
|
+
|
|
294
|
+
def __init__(self, output_queue: asyncio.Queue[TerminalMessage]) -> None:
|
|
295
|
+
self._sessions: dict[str, TerminalSession] = {}
|
|
296
|
+
self._lock = asyncio.Lock()
|
|
297
|
+
self._websocket: object | None = None
|
|
298
|
+
self._output_queue = output_queue
|
|
299
|
+
|
|
300
|
+
# Send reset message immediately to clear stale sessions
|
|
301
|
+
try:
|
|
302
|
+
reset_message = TerminalResetSessions()
|
|
303
|
+
self._output_queue.put_nowait(reset_message)
|
|
304
|
+
logger.info("Sent TerminalResetSessions message")
|
|
305
|
+
except asyncio.QueueFull:
|
|
306
|
+
logger.error("Failed to queue terminal reset message - queue full")
|
|
307
|
+
|
|
308
|
+
def set_websocket(self, websocket: object) -> None:
|
|
309
|
+
"""Set the websocket for sending output"""
|
|
310
|
+
self._websocket = websocket
|
|
311
|
+
|
|
312
|
+
async def start_session(
|
|
313
|
+
self,
|
|
314
|
+
websocket: object,
|
|
315
|
+
session_id: str,
|
|
316
|
+
command: list[str] | None = None,
|
|
317
|
+
cols: int = 80,
|
|
318
|
+
rows: int = 24,
|
|
319
|
+
env: dict[str, str] | None = None,
|
|
320
|
+
) -> str:
|
|
321
|
+
"""Start a new terminal session"""
|
|
322
|
+
async with self._lock:
|
|
323
|
+
if session_id in self._sessions:
|
|
324
|
+
raise RuntimeError(f"Terminal session {session_id} already exists")
|
|
325
|
+
|
|
326
|
+
# Store websocket reference
|
|
327
|
+
self._websocket = websocket
|
|
328
|
+
|
|
329
|
+
# Create output callback that queues data to be sent
|
|
330
|
+
def output_callback(data: str) -> None:
|
|
331
|
+
# Queue the output to be sent asynchronously
|
|
332
|
+
try:
|
|
333
|
+
terminal_output = TerminalOutput(
|
|
334
|
+
session_id=session_id,
|
|
335
|
+
data=data,
|
|
336
|
+
timestamp=time.time(),
|
|
337
|
+
)
|
|
338
|
+
self._output_queue.put_nowait(terminal_output)
|
|
339
|
+
except asyncio.QueueFull:
|
|
340
|
+
logger.error(
|
|
341
|
+
"Terminal output queue full",
|
|
342
|
+
session_id=session_id,
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
session = TerminalSession(
|
|
346
|
+
session_id=session_id,
|
|
347
|
+
output_callback=output_callback,
|
|
348
|
+
cols=cols,
|
|
349
|
+
rows=rows,
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
await session.start(command=command, env=env)
|
|
353
|
+
self._sessions[session_id] = session
|
|
354
|
+
|
|
355
|
+
return session_id
|
|
356
|
+
|
|
357
|
+
async def send_input(self, session_id: str, data: str) -> bool:
|
|
358
|
+
"""Send input to a terminal session"""
|
|
359
|
+
async with self._lock:
|
|
360
|
+
session = self._sessions.get(session_id)
|
|
361
|
+
if session is None:
|
|
362
|
+
return False
|
|
363
|
+
|
|
364
|
+
try:
|
|
365
|
+
await session.write_input(data)
|
|
366
|
+
return True
|
|
367
|
+
except Exception as e:
|
|
368
|
+
logger.error(
|
|
369
|
+
"Failed to send input to terminal",
|
|
370
|
+
session_id=session_id,
|
|
371
|
+
error=str(e),
|
|
372
|
+
)
|
|
373
|
+
return False
|
|
374
|
+
|
|
375
|
+
async def resize_terminal(self, session_id: str, rows: int, cols: int) -> bool:
|
|
376
|
+
"""Resize a terminal session"""
|
|
377
|
+
async with self._lock:
|
|
378
|
+
session = self._sessions.get(session_id)
|
|
379
|
+
if session is None:
|
|
380
|
+
return False
|
|
381
|
+
|
|
382
|
+
try:
|
|
383
|
+
session.resize(cols, rows)
|
|
384
|
+
return True
|
|
385
|
+
except Exception as e:
|
|
386
|
+
logger.error(
|
|
387
|
+
"Failed to resize terminal",
|
|
388
|
+
session_id=session_id,
|
|
389
|
+
error=str(e),
|
|
390
|
+
)
|
|
391
|
+
return False
|
|
392
|
+
|
|
393
|
+
async def stop_session(self, session_id: str) -> bool:
|
|
394
|
+
"""Stop a terminal session"""
|
|
395
|
+
async with self._lock:
|
|
396
|
+
session = self._sessions.pop(session_id, None)
|
|
397
|
+
if session is None:
|
|
398
|
+
return True # Already stopped
|
|
399
|
+
|
|
400
|
+
try:
|
|
401
|
+
await session.stop()
|
|
402
|
+
return True
|
|
403
|
+
except Exception as e:
|
|
404
|
+
logger.error(
|
|
405
|
+
"Failed to stop terminal",
|
|
406
|
+
session_id=session_id,
|
|
407
|
+
error=str(e),
|
|
408
|
+
)
|
|
409
|
+
return False
|
|
410
|
+
|
|
411
|
+
async def stop_all_sessions(self) -> None:
|
|
412
|
+
"""Stop all terminal sessions (cleanup on disconnect)"""
|
|
413
|
+
async with self._lock:
|
|
414
|
+
session_ids = list(self._sessions.keys())
|
|
415
|
+
for session_id in session_ids:
|
|
416
|
+
session = self._sessions.pop(session_id, None)
|
|
417
|
+
if session:
|
|
418
|
+
try:
|
|
419
|
+
await session.stop()
|
|
420
|
+
logger.info(
|
|
421
|
+
"Stopped terminal session on cleanup",
|
|
422
|
+
session_id=session_id,
|
|
423
|
+
)
|
|
424
|
+
except Exception as e:
|
|
425
|
+
logger.error(
|
|
426
|
+
"Error stopping terminal session on cleanup",
|
|
427
|
+
session_id=session_id,
|
|
428
|
+
error=str(e),
|
|
429
|
+
)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Type definitions for terminal output streaming."""
|
|
2
|
+
|
|
3
|
+
import msgspec
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class TerminalOutput(msgspec.Struct, tag="terminal_output"):
|
|
7
|
+
"""Terminal output data from CLI to web client."""
|
|
8
|
+
|
|
9
|
+
session_id: str
|
|
10
|
+
data: str
|
|
11
|
+
timestamp: float
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TerminalStatus(msgspec.Struct, tag="terminal_status"):
|
|
15
|
+
"""Terminal status update from CLI to web client."""
|
|
16
|
+
|
|
17
|
+
session_id: str
|
|
18
|
+
status: str
|
|
19
|
+
message: str
|
|
20
|
+
exit_code: int | None = None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class TerminalResetSessions(msgspec.Struct, tag="terminal_reset_sessions"):
|
|
24
|
+
"""Sent from CLI when terminal session manager starts to clear stale sessions."""
|
|
25
|
+
|
|
26
|
+
# No fields needed - just a signal
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
TerminalMessage = TerminalOutput | TerminalStatus | TerminalResetSessions
|
|
@@ -32,15 +32,15 @@ from exponent.core.remote_execution.cli_rpc_types import (
|
|
|
32
32
|
|
|
33
33
|
if TYPE_CHECKING:
|
|
34
34
|
from exponent.core.remote_execution.client import RemoteExecutionClient
|
|
35
|
+
from exponent.core.remote_execution.cli_rpc_types import (
|
|
36
|
+
StreamingCodeExecutionRequest,
|
|
37
|
+
StreamingCodeExecutionResponse,
|
|
38
|
+
)
|
|
35
39
|
from exponent.core.remote_execution.code_execution import (
|
|
36
40
|
execute_code_streaming,
|
|
37
41
|
)
|
|
38
42
|
from exponent.core.remote_execution.file_write import execute_full_file_rewrite
|
|
39
43
|
from exponent.core.remote_execution.truncation import truncate_tool_result
|
|
40
|
-
from exponent.core.remote_execution.types import (
|
|
41
|
-
StreamingCodeExecutionRequest,
|
|
42
|
-
StreamingCodeExecutionResponse,
|
|
43
|
-
)
|
|
44
44
|
from exponent.core.remote_execution.utils import (
|
|
45
45
|
assert_unreachable,
|
|
46
46
|
safe_get_file_metadata,
|
|
@@ -17,7 +17,7 @@ from exponent.core.remote_execution.cli_rpc_types import (
|
|
|
17
17
|
)
|
|
18
18
|
from exponent.core.remote_execution.utils import truncate_output
|
|
19
19
|
|
|
20
|
-
DEFAULT_CHARACTER_LIMIT =
|
|
20
|
+
DEFAULT_CHARACTER_LIMIT = 50_000
|
|
21
21
|
DEFAULT_LIST_ITEM_LIMIT = 1000
|
|
22
22
|
DEFAULT_LIST_PREVIEW_ITEMS = 10
|
|
23
23
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
exponent/__init__.py,sha256=
|
|
1
|
+
exponent/__init__.py,sha256=eZj6tqY-zTtH5r_8y6a4Vovz6LQ_hDHSesTIiwuyahQ,706
|
|
2
2
|
exponent/cli.py,sha256=QnIeDTgWaQJrRs5WESCkQpVEQiJiAO4qWgB0rYlkd78,3344
|
|
3
3
|
exponent/py.typed,sha256=9XZl5avs8yHp89XP_1Fjtbeg_2rjYorCC9I0k_j-h2c,334
|
|
4
4
|
exponent/commands/cloud_commands.py,sha256=yd0d7l8AaFZIgrFPRYspsKlmcyFqkK_ovSQKhK-YpVU,16773
|
|
@@ -17,9 +17,9 @@ exponent/core/graphql/mutations.py,sha256=Szs8wS_5EpVuZdt09QbstIm_8i-_-EGT4Z17po
|
|
|
17
17
|
exponent/core/graphql/queries.py,sha256=RYsk8bub0esspqgakilhzX07yJf2652Ey9tBZK1l_lY,3297
|
|
18
18
|
exponent/core/graphql/subscriptions.py,sha256=SQngv_nYVNJjiZ_P2k0UcLIu1pzc4vi7q7lhH89NCZM,393
|
|
19
19
|
exponent/core/remote_execution/checkpoints.py,sha256=3QGYMLa8vT7XmxMYTRcGrW8kNGHwRC0AkUfULribJWg,6354
|
|
20
|
-
exponent/core/remote_execution/cli_rpc_types.py,sha256=
|
|
21
|
-
exponent/core/remote_execution/client.py,sha256=
|
|
22
|
-
exponent/core/remote_execution/code_execution.py,sha256=
|
|
20
|
+
exponent/core/remote_execution/cli_rpc_types.py,sha256=RPlvZYb7KLYlb65I8wcVAlkv3cSB7n2okZqMeWz_V4c,11791
|
|
21
|
+
exponent/core/remote_execution/client.py,sha256=bUl2w28c_okWndW9jEreVyYKeFNyL8h8OMZNBb4lf3g,40307
|
|
22
|
+
exponent/core/remote_execution/code_execution.py,sha256=QL78v2yHMrIcbNWIczAICWRceziXgG4pzw7gvhUOLxs,3328
|
|
23
23
|
exponent/core/remote_execution/default_env.py,sha256=s44A1Cz9EgYuhF17WO3ESVNSLQw57EoOLyi9k6qliIo,911
|
|
24
24
|
exponent/core/remote_execution/error_info.py,sha256=Rd7OA3ps06qYejPVcOaMBB9AtftP3wqQoOfiILFASnc,1378
|
|
25
25
|
exponent/core/remote_execution/exceptions.py,sha256=eT57lBnBhvh-KJ5lsKWcfgGA5-WisAxhjZx-Z6OupZY,135
|
|
@@ -30,13 +30,15 @@ exponent/core/remote_execution/http_fetch.py,sha256=aFEyXd0S-MRfisSMuIFiEyc1AEAj
|
|
|
30
30
|
exponent/core/remote_execution/port_utils.py,sha256=kWje8ikCzBXMeS7qr6NZZOzQOMoMuacgPUDYyloYgwM,2183
|
|
31
31
|
exponent/core/remote_execution/session.py,sha256=jlQIdeUj0f7uOk3BgzlJtBJ_GyTIjCchBp5ApQuF2-I,3847
|
|
32
32
|
exponent/core/remote_execution/system_context.py,sha256=I4RNuM60isS-529EuRrrEPPwJssNFC2TZ_7MhBTWEd0,754
|
|
33
|
-
exponent/core/remote_execution/
|
|
33
|
+
exponent/core/remote_execution/terminal_session.py,sha256=s0ANsr_AGIfCI5u6PSvioOlnbk7ON43YAc30la5k3TM,14219
|
|
34
|
+
exponent/core/remote_execution/terminal_types.py,sha256=t4snBiTtidAEJZTvy789x-5HFqjkV9rlonjDO30PfAY,731
|
|
35
|
+
exponent/core/remote_execution/tool_execution.py,sha256=tDVW1c4ZysfTZqbR-wd7et41Mfv1lFQJCBvQShWnWq4,15791
|
|
34
36
|
exponent/core/remote_execution/tool_type_utils.py,sha256=7qi6Qd8fvHts019ZSLPbtiy17BUqgqBg3P_gdfvFf7w,1301
|
|
35
|
-
exponent/core/remote_execution/truncation.py,sha256=
|
|
37
|
+
exponent/core/remote_execution/truncation.py,sha256=0zFnmqXES2vtQCSpfXIQn5hgg6bZK4Sad_Cfh27xTZU,9849
|
|
36
38
|
exponent/core/remote_execution/types.py,sha256=2tp73g6WLhL3x-5FyP9jhadcRHIswt4wfJJlEvNwlvk,15782
|
|
37
39
|
exponent/core/remote_execution/utils.py,sha256=6PlBqYJ3OQwZ0dgXiIu3br04a-d-glDeDZpD0XGGPAE,14793
|
|
38
40
|
exponent/core/remote_execution/languages/python_execution.py,sha256=nsX_LsXcUcHhiEHpSTjOTVNd7CxM146al0kw_iQX5OU,7724
|
|
39
|
-
exponent/core/remote_execution/languages/shell_streaming.py,sha256=
|
|
41
|
+
exponent/core/remote_execution/languages/shell_streaming.py,sha256=MpE1XQiu18xWUGp1wD_Hb1nuPCQE-i5-_XO6FnkcNvo,7675
|
|
40
42
|
exponent/core/remote_execution/languages/types.py,sha256=f7FjSRNRSga-ZaE3LddDhxCirUVjlSYMEdoskG6Pta4,314
|
|
41
43
|
exponent/core/types/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
42
44
|
exponent/core/types/command_data.py,sha256=_HqQsnamRZeVoVaTpeO3ecVUzNBdG62WXlFy6Q7rtUM,5294
|
|
@@ -47,7 +49,7 @@ exponent/migration-docs/login.md,sha256=KIeXy3m2nzSUgw-4PW1XzXfHael1D4Zu93CplLMb
|
|
|
47
49
|
exponent/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
48
50
|
exponent/utils/colors.py,sha256=HBkqe_ZmhJ9YiL2Fpulqek4KvLS5mwBTY4LQSM5N8SM,2762
|
|
49
51
|
exponent/utils/version.py,sha256=GHZ9ET1kMyDubJZU3w2sah5Pw8XpiEakS5IOlt3wUnQ,8888
|
|
50
|
-
indent-0.1.
|
|
51
|
-
indent-0.1.
|
|
52
|
-
indent-0.1.
|
|
53
|
-
indent-0.1.
|
|
52
|
+
indent-0.1.23.dist-info/METADATA,sha256=WOFHXH8yRn6F_tajQ4B1H9eIxo5RxFcUHVFKhOps2nM,1340
|
|
53
|
+
indent-0.1.23.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
54
|
+
indent-0.1.23.dist-info/entry_points.txt,sha256=q8q1t1sbl4NULGOR0OV5RmSG4KEjkpEQRU_RUXEGzcs,44
|
|
55
|
+
indent-0.1.23.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|