indent 0.1.13__py3-none-any.whl → 0.1.28__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 (31) hide show
  1. exponent/__init__.py +2 -2
  2. exponent/cli.py +0 -2
  3. exponent/commands/cloud_commands.py +2 -87
  4. exponent/commands/common.py +25 -40
  5. exponent/commands/config_commands.py +0 -87
  6. exponent/commands/run_commands.py +5 -2
  7. exponent/core/config.py +1 -1
  8. exponent/core/container_build/__init__.py +0 -0
  9. exponent/core/container_build/types.py +25 -0
  10. exponent/core/graphql/mutations.py +2 -31
  11. exponent/core/graphql/queries.py +0 -3
  12. exponent/core/remote_execution/cli_rpc_types.py +201 -5
  13. exponent/core/remote_execution/client.py +355 -92
  14. exponent/core/remote_execution/code_execution.py +26 -7
  15. exponent/core/remote_execution/default_env.py +31 -0
  16. exponent/core/remote_execution/languages/shell_streaming.py +11 -6
  17. exponent/core/remote_execution/port_utils.py +73 -0
  18. exponent/core/remote_execution/system_context.py +2 -0
  19. exponent/core/remote_execution/terminal_session.py +517 -0
  20. exponent/core/remote_execution/terminal_types.py +29 -0
  21. exponent/core/remote_execution/tool_execution.py +228 -18
  22. exponent/core/remote_execution/tool_type_utils.py +39 -0
  23. exponent/core/remote_execution/truncation.py +9 -1
  24. exponent/core/remote_execution/types.py +71 -19
  25. exponent/utils/version.py +8 -7
  26. {indent-0.1.13.dist-info → indent-0.1.28.dist-info}/METADATA +5 -2
  27. {indent-0.1.13.dist-info → indent-0.1.28.dist-info}/RECORD +29 -24
  28. exponent/commands/workflow_commands.py +0 -111
  29. exponent/core/graphql/github_config_queries.py +0 -56
  30. {indent-0.1.13.dist-info → indent-0.1.28.dist-info}/WHEEL +0 -0
  31. {indent-0.1.13.dist-info → indent-0.1.28.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,517 @@
1
+ import asyncio
2
+ import codecs
3
+ import fcntl
4
+ import logging
5
+ import os
6
+ import signal
7
+ import struct
8
+ import sys
9
+ import termios
10
+ import time
11
+ import traceback
12
+ from collections.abc import Callable
13
+
14
+ from exponent.core.remote_execution.terminal_types import (
15
+ TerminalMessage,
16
+ TerminalOutput,
17
+ TerminalResetSessions,
18
+ )
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ class TerminalSession:
24
+ """
25
+ Manages a PTY session for terminal emulation.
26
+ Runs on the CLI machine and streams output back to server.
27
+ """
28
+
29
+ def __init__(
30
+ self,
31
+ session_id: str,
32
+ output_callback: Callable[[str], None],
33
+ cols: int = 80,
34
+ rows: int = 24,
35
+ ):
36
+ self.session_id = session_id
37
+ self.output_callback = output_callback # Called with terminal output
38
+ self.cols = cols
39
+ self.rows = rows
40
+ self.master_fd: int | None = None
41
+ self.pid: int | None = None
42
+ self._running = False
43
+ self._read_task: asyncio.Task[None] | None = None
44
+
45
+ def _cleanup_fds(self, master_fd: int | None, slave_fd: int | None) -> None:
46
+ """Clean up file descriptors on error."""
47
+ if master_fd is not None:
48
+ try:
49
+ os.close(master_fd)
50
+ except OSError:
51
+ pass
52
+ if slave_fd is not None:
53
+ try:
54
+ os.close(slave_fd)
55
+ except OSError:
56
+ pass
57
+
58
+ def _setup_child_process(
59
+ self, slave_fd: int, command: list[str], env: dict[str, str] | None
60
+ ) -> None:
61
+ """Set up child process with PTY slave as controlling terminal."""
62
+ # Create new session and set controlling terminal
63
+ os.setsid()
64
+
65
+ # Redirect stdin/stdout/stderr to slave PTY
66
+ os.dup2(slave_fd, 0)
67
+ os.dup2(slave_fd, 1)
68
+ os.dup2(slave_fd, 2)
69
+
70
+ # Close the slave fd after duplication
71
+ if slave_fd > 2:
72
+ os.close(slave_fd)
73
+
74
+ # Close master fd in child
75
+ if self.master_fd is not None:
76
+ os.close(self.master_fd)
77
+
78
+ # Set up environment
79
+ if env:
80
+ for key, value in env.items():
81
+ os.environ[key] = value
82
+
83
+ # Set terminal environment
84
+ os.environ["TERM"] = "xterm-256color"
85
+ os.environ["COLORTERM"] = "truecolor"
86
+ os.environ["TERM_PROGRAM"] = "indent"
87
+ # Remove TERM_PROGRAM_VERSION if it exists
88
+ os.environ.pop("TERM_PROGRAM_VERSION", None)
89
+
90
+ # Execute command
91
+ os.execvp(command[0], command)
92
+
93
+ async def start( # noqa: PLR0915
94
+ self,
95
+ command: list[str] | None = None,
96
+ env: dict[str, str] | None = None,
97
+ ) -> None:
98
+ """Start the terminal session with PTY"""
99
+ if self._running:
100
+ raise RuntimeError(f"Terminal session {self.session_id} already running")
101
+
102
+ # Default to user's shell if no command specified
103
+ if command is None:
104
+ # Get user's default shell from environment, fallback to /bin/bash
105
+ default_shell = os.environ.get("SHELL", "/bin/bash")
106
+ command = [default_shell]
107
+
108
+ # Create PTY with window size set BEFORE fork to eliminate race condition
109
+ master_fd = None
110
+ slave_fd = None
111
+ try:
112
+ # Open PTY pair
113
+ master_fd, slave_fd = os.openpty()
114
+
115
+ # Set window size on PTY before forking
116
+ # This ensures the child process sees correct dimensions from the start
117
+ size = struct.pack("HHHH", self.rows, self.cols, 0, 0)
118
+ fcntl.ioctl(master_fd, termios.TIOCSWINSZ, size)
119
+
120
+ # Fork process
121
+ self.pid = os.fork()
122
+ self.master_fd = master_fd
123
+ except OSError as e:
124
+ # Clean up FDs if anything failed
125
+ self._cleanup_fds(master_fd, slave_fd)
126
+ logger.error(
127
+ "Failed to create PTY or fork",
128
+ )
129
+ raise RuntimeError(f"Failed to create PTY or fork: {e}") from e
130
+
131
+ if self.pid == 0:
132
+ # Child process - set up PTY slave as controlling terminal
133
+ try:
134
+ self._setup_child_process(slave_fd, command, env)
135
+ except Exception as e:
136
+ # If exec fails, log and exit child process
137
+ traceback.print_exc()
138
+ sys.stderr.write(f"Failed to execute command {command}: {e}\n")
139
+ sys.stderr.flush()
140
+ os._exit(1)
141
+ else:
142
+ # Parent process - close slave fd (only child needs it)
143
+ os.close(slave_fd)
144
+
145
+ # Set up non-blocking I/O on master
146
+ flags = fcntl.fcntl(self.master_fd, fcntl.F_GETFL)
147
+ fcntl.fcntl(self.master_fd, fcntl.F_SETFL, flags | os.O_NONBLOCK)
148
+
149
+ # Configure terminal attributes
150
+ try:
151
+ attrs = termios.tcgetattr(self.master_fd)
152
+
153
+ # Local flags (lflags)
154
+ # Enable: icanon isig iexten echo echoe echoke echoctl pendin
155
+ attrs[3] |= (
156
+ termios.ICANON
157
+ | termios.ISIG
158
+ | termios.IEXTEN
159
+ | termios.ECHO
160
+ | termios.ECHOE
161
+ | termios.ECHOKE
162
+ | termios.ECHOCTL
163
+ )
164
+ # Disable: echok echonl echoprt noflsh tostop
165
+ attrs[3] &= ~(
166
+ termios.ECHOK
167
+ | termios.ECHONL
168
+ | termios.ECHOPRT
169
+ | termios.NOFLSH
170
+ | termios.TOSTOP
171
+ )
172
+ if hasattr(termios, "PENDIN"):
173
+ attrs[3] |= termios.PENDIN
174
+
175
+ # Input flags (iflags)
176
+ # Enable: icrnl ixon ixany imaxbel brkint
177
+ attrs[0] |= (
178
+ termios.ICRNL
179
+ | termios.IXON
180
+ | termios.IXANY
181
+ | termios.IMAXBEL
182
+ | termios.BRKINT
183
+ )
184
+ # Disable: istrip inlcr igncr ixoff ignbrk inpck ignpar parmrk
185
+ attrs[0] &= ~(
186
+ termios.ISTRIP
187
+ | termios.INLCR
188
+ | termios.IGNCR
189
+ | termios.IXOFF
190
+ | termios.IGNBRK
191
+ | termios.INPCK
192
+ | termios.IGNPAR
193
+ | termios.PARMRK
194
+ )
195
+
196
+ # Output flags (oflags)
197
+ # Enable: opost onlcr
198
+ attrs[1] |= termios.OPOST | termios.ONLCR
199
+ # Disable: onocr onlret
200
+ attrs[1] &= ~(termios.ONOCR | termios.ONLRET)
201
+
202
+ # Control flags (cflags)
203
+ # Enable: cread cs8 hupcl
204
+ attrs[2] |= termios.CREAD | termios.CS8 | termios.HUPCL
205
+ # Disable: parenb parodd clocal cstopb crtscts
206
+ attrs[2] &= ~(
207
+ termios.PARENB | termios.PARODD | termios.CLOCAL | termios.CSTOPB
208
+ )
209
+ if hasattr(termios, "CRTSCTS"):
210
+ attrs[2] &= ~termios.CRTSCTS
211
+
212
+ # Control characters (cchars)
213
+ attrs[6][termios.VEOF] = ord("\x04") # ^D
214
+ attrs[6][termios.VERASE] = 0x7F # ^? (DEL)
215
+ attrs[6][termios.VINTR] = ord("\x03") # ^C
216
+ attrs[6][termios.VKILL] = ord("\x15") # ^U
217
+ attrs[6][termios.VQUIT] = ord("\x1c") # ^\
218
+ attrs[6][termios.VSUSP] = ord("\x1a") # ^Z
219
+ attrs[6][termios.VSTART] = ord("\x11") # ^Q
220
+ attrs[6][termios.VSTOP] = ord("\x13") # ^S
221
+ attrs[6][termios.VMIN] = 1
222
+ attrs[6][termios.VTIME] = 0
223
+
224
+ termios.tcsetattr(self.master_fd, termios.TCSANOW, attrs)
225
+ except Exception as e:
226
+ logger.warning(f"Failed to set terminal attributes: {e}")
227
+
228
+ # Start reading from PTY
229
+ self._running = True
230
+ self._read_task = asyncio.create_task(self._read_from_pty())
231
+
232
+ async def _read_from_pty(self) -> None:
233
+ """Continuously read from PTY using event loop's add_reader (non-blocking)"""
234
+ if self.master_fd is None:
235
+ return
236
+
237
+ loop = asyncio.get_event_loop()
238
+ read_queue: asyncio.Queue[bytes | None] = asyncio.Queue()
239
+
240
+ def read_callback() -> None:
241
+ """Called by event loop when data is available on the FD"""
242
+ if self.master_fd is None:
243
+ return
244
+ try:
245
+ data = os.read(self.master_fd, 4096)
246
+ if data:
247
+ # Put data in queue to be processed by async task
248
+ read_queue.put_nowait(data)
249
+ else:
250
+ # EOF - PTY closed
251
+ read_queue.put_nowait(None)
252
+ except OSError as e:
253
+ if e.errno == 11: # EAGAIN - shouldn't happen with add_reader
254
+ pass
255
+ else:
256
+ read_queue.put_nowait(None)
257
+ except Exception:
258
+ logger.error(
259
+ "Unexpected error in PTY read callback",
260
+ )
261
+ read_queue.put_nowait(None)
262
+
263
+ # Register the FD with the event loop
264
+ loop.add_reader(self.master_fd, read_callback)
265
+
266
+ # Use incremental decoder to handle partial UTF-8 sequences
267
+
268
+ decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
269
+
270
+ try:
271
+ while self._running:
272
+ # Wait for data from the queue (non-blocking for event loop)
273
+ data = await read_queue.get()
274
+
275
+ if data is None:
276
+ # EOF or error - flush any remaining bytes in decoder
277
+ final = decoder.decode(b"", final=True)
278
+ if final:
279
+ self.output_callback(final)
280
+ break
281
+
282
+ # Process the data with incremental decoder
283
+ # This handles partial UTF-8 sequences correctly
284
+ decoded = decoder.decode(data, final=False)
285
+ if decoded:
286
+ self.output_callback(decoded)
287
+ finally:
288
+ # Unregister the FD from the event loop
289
+ loop.remove_reader(self.master_fd)
290
+
291
+ async def write_input(self, data: str) -> None:
292
+ """Write user input to PTY"""
293
+ if not self._running or self.master_fd is None:
294
+ raise RuntimeError(f"Terminal session {self.session_id} not running")
295
+
296
+ try:
297
+ os.write(self.master_fd, data.encode("utf-8"))
298
+ except OSError:
299
+ logger.error(
300
+ "Error writing to PTY",
301
+ )
302
+ raise
303
+
304
+ def resize(self, cols: int, rows: int) -> None:
305
+ """Resize the PTY to match terminal dimensions"""
306
+ if self.master_fd is None:
307
+ return
308
+
309
+ self.cols = cols
310
+ self.rows = rows
311
+
312
+ try:
313
+ size = struct.pack("HHHH", rows, cols, 0, 0)
314
+ fcntl.ioctl(self.master_fd, termios.TIOCSWINSZ, size)
315
+ except Exception:
316
+ logger.error(
317
+ "Error resizing PTY",
318
+ )
319
+
320
+ async def stop(self) -> tuple[bool, int | None]:
321
+ """
322
+ Stop the terminal session and clean up resources.
323
+ Returns (success, exit_code)
324
+ """
325
+ if not self._running:
326
+ return True, None
327
+
328
+ self._running = False
329
+
330
+ # Cancel read task
331
+ if self._read_task and not self._read_task.done():
332
+ self._read_task.cancel()
333
+ try:
334
+ await self._read_task
335
+ except asyncio.CancelledError:
336
+ pass
337
+
338
+ exit_code = None
339
+
340
+ # Close file descriptor
341
+ if self.master_fd is not None:
342
+ try:
343
+ os.close(self.master_fd)
344
+ except Exception:
345
+ logger.error(
346
+ "Error closing PTY fd",
347
+ )
348
+ self.master_fd = None
349
+
350
+ # Kill child process
351
+ if self.pid is not None:
352
+ try:
353
+ os.kill(self.pid, signal.SIGTERM)
354
+ # Wait for process to terminate (with timeout)
355
+ for _ in range(10): # Wait up to 1 second
356
+ try:
357
+ pid, status = os.waitpid(self.pid, os.WNOHANG)
358
+ if pid != 0:
359
+ exit_code = os.WEXITSTATUS(status)
360
+ break
361
+ except ChildProcessError:
362
+ break
363
+ await asyncio.sleep(0.1)
364
+ else:
365
+ # Force kill if still running
366
+ try:
367
+ os.kill(self.pid, signal.SIGKILL)
368
+ os.waitpid(self.pid, 0)
369
+ except Exception:
370
+ pass
371
+ except Exception:
372
+ logger.error(
373
+ "Error killing PTY process",
374
+ )
375
+ self.pid = None
376
+
377
+ logger.info(
378
+ "Terminal session stopped",
379
+ )
380
+
381
+ return True, exit_code
382
+
383
+ @property
384
+ def is_running(self) -> bool:
385
+ """Check if terminal session is running"""
386
+ return self._running and self.master_fd is not None
387
+
388
+
389
+ class TerminalSessionManager:
390
+ """Manages multiple terminal sessions"""
391
+
392
+ def __init__(self, output_queue: asyncio.Queue[TerminalMessage]) -> None:
393
+ self._sessions: dict[str, TerminalSession] = {}
394
+ self._lock = asyncio.Lock()
395
+ self._websocket: object | None = None
396
+ self._output_queue = output_queue
397
+
398
+ # Send reset message immediately to clear stale sessions
399
+ try:
400
+ reset_message = TerminalResetSessions()
401
+ self._output_queue.put_nowait(reset_message)
402
+ logger.info("Sent TerminalResetSessions message")
403
+ except asyncio.QueueFull:
404
+ logger.error("Failed to queue terminal reset message - queue full")
405
+
406
+ def set_websocket(self, websocket: object) -> None:
407
+ """Set the websocket for sending output"""
408
+ self._websocket = websocket
409
+
410
+ async def start_session(
411
+ self,
412
+ websocket: object,
413
+ session_id: str,
414
+ command: list[str] | None = None,
415
+ cols: int = 80,
416
+ rows: int = 24,
417
+ env: dict[str, str] | None = None,
418
+ ) -> str:
419
+ """Start a new terminal session"""
420
+ async with self._lock:
421
+ if session_id in self._sessions:
422
+ raise RuntimeError(f"Terminal session {session_id} already exists")
423
+
424
+ # Store websocket reference
425
+ self._websocket = websocket
426
+
427
+ # Create output callback that queues data to be sent
428
+ def output_callback(data: str) -> None:
429
+ # Queue the output to be sent asynchronously
430
+ try:
431
+ terminal_output = TerminalOutput(
432
+ session_id=session_id,
433
+ data=data,
434
+ timestamp=time.time(),
435
+ )
436
+ self._output_queue.put_nowait(terminal_output)
437
+ except asyncio.QueueFull:
438
+ logger.error(
439
+ "Terminal output queue full",
440
+ )
441
+
442
+ session = TerminalSession(
443
+ session_id=session_id,
444
+ output_callback=output_callback,
445
+ cols=cols,
446
+ rows=rows,
447
+ )
448
+
449
+ await session.start(command=command, env=env)
450
+ self._sessions[session_id] = session
451
+
452
+ return session_id
453
+
454
+ async def send_input(self, session_id: str, data: str) -> bool:
455
+ """Send input to a terminal session"""
456
+ async with self._lock:
457
+ session = self._sessions.get(session_id)
458
+ if session is None:
459
+ return False
460
+
461
+ try:
462
+ await session.write_input(data)
463
+ return True
464
+ except Exception:
465
+ logger.error(
466
+ "Failed to send input to terminal",
467
+ )
468
+ return False
469
+
470
+ async def resize_terminal(self, session_id: str, rows: int, cols: int) -> bool:
471
+ """Resize a terminal session"""
472
+ async with self._lock:
473
+ session = self._sessions.get(session_id)
474
+ if session is None:
475
+ return False
476
+
477
+ try:
478
+ session.resize(cols, rows)
479
+ return True
480
+ except Exception:
481
+ logger.error(
482
+ "Failed to resize terminal",
483
+ )
484
+ return False
485
+
486
+ async def stop_session(self, session_id: str) -> bool:
487
+ """Stop a terminal session"""
488
+ async with self._lock:
489
+ session = self._sessions.pop(session_id, None)
490
+ if session is None:
491
+ return True # Already stopped
492
+
493
+ try:
494
+ await session.stop()
495
+ return True
496
+ except Exception:
497
+ logger.error(
498
+ "Failed to stop terminal",
499
+ )
500
+ return False
501
+
502
+ async def stop_all_sessions(self) -> None:
503
+ """Stop all terminal sessions (cleanup on disconnect)"""
504
+ async with self._lock:
505
+ session_ids = list(self._sessions.keys())
506
+ for session_id in session_ids:
507
+ session = self._sessions.pop(session_id, None)
508
+ if session:
509
+ try:
510
+ await session.stop()
511
+ logger.info(
512
+ "Stopped terminal session on cleanup",
513
+ )
514
+ except Exception:
515
+ logger.error(
516
+ "Error stopping terminal session on cleanup",
517
+ )
@@ -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