Merge branch 'dev' of ssh://git.commumedia.org:2222/alex/headquarter into dev

This commit is contained in:
2026-05-24 19:20:32 +00:00
3 changed files with 74 additions and 33 deletions
+31 -11
View File
@@ -15,6 +15,13 @@ router = APIRouter()
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class SessionRef:
"""Mutable reference to a terminal session, allowing updates during reset."""
def __init__(self, session):
self.session = session
@router.websocket( @router.websocket(
"/ws/tool-instances/{instance_id}/terminal", "/ws/tool-instances/{instance_id}/terminal",
) )
@@ -86,9 +93,12 @@ async def terminal_websocket(
# Send connected status # Send connected status
await websocket.send_json({"type": "status", "status": "connected"}) await websocket.send_json({"type": "status", "status": "connected"})
# Use mutable session reference so loops can survive reset
session_ref = SessionRef(session)
# Start I/O loops and heartbeat # Start I/O loops and heartbeat
read_task = asyncio.create_task(_read_loop(session, websocket)) read_task = asyncio.create_task(_read_loop(session_ref, websocket))
write_task = asyncio.create_task(_write_loop(session, websocket, instance_id)) write_task = asyncio.create_task(_write_loop(session_ref, websocket, instance_id))
heartbeat_task = asyncio.create_task(_heartbeat_loop(websocket)) heartbeat_task = asyncio.create_task(_heartbeat_loop(websocket))
# Wait for either task to complete (indicating disconnect or error) # Wait for either task to complete (indicating disconnect or error)
@@ -114,10 +124,14 @@ async def terminal_websocket(
pass pass
async def _read_loop(session, websocket) -> None: async def _read_loop(session_ref: SessionRef, websocket) -> None:
"""Read output from the container and send to WebSocket.""" """Read output from the container and send to WebSocket."""
try: try:
while session.is_alive() and not session._closed: while True:
session = session_ref.session
if not session.is_alive() or session._closed:
await asyncio.sleep(0.1)
continue
data = await session.read_output() data = await session.read_output()
if data: if data:
try: try:
@@ -130,10 +144,14 @@ async def _read_loop(session, websocket) -> None:
pass pass
async def _write_loop(session, websocket, instance_id: str) -> None: async def _write_loop(session_ref: SessionRef, websocket, instance_id: str) -> None:
"""Read input from WebSocket and send to container.""" """Read input from WebSocket and send to container."""
try: try:
while session.is_alive() and not session._closed: while True:
session = session_ref.session
if not session.is_alive() or session._closed:
await asyncio.sleep(0.1)
continue
message = await websocket.receive() message = await websocket.receive()
if message["type"] == "websocket.receive": if message["type"] == "websocket.receive":
if "bytes" in message: if "bytes" in message:
@@ -163,17 +181,19 @@ async def _write_loop(session, websocket, instance_id: str) -> None:
session.container_id, session.container_id,
) )
# Update the mutable session reference so read_loop uses the new session
session_ref.session = new_session
# Attach to new session # Attach to new session
await terminal_manager.attach_websocket(new_session, websocket) await terminal_manager.attach_websocket(new_session, websocket)
await websocket.send_json({"type": "status", "status": "connected"}) await websocket.send_json({"type": "status", "status": "connected"})
# Update session reference and restart loops # Continue the loop with the new session
# Note: This will cause the current loops to exit continue
# The WebSocket handler will create new ones
return
except json.JSONDecodeError: except json.JSONDecodeError:
pass # Not a valid JSON control message, treat as regular input
await session.write_input(text.encode("utf-8"))
else: else:
await session.write_input(text.encode("utf-8")) await session.write_input(text.encode("utf-8"))
elif message["type"] == "websocket.disconnect": elif message["type"] == "websocket.disconnect":
+14 -10
View File
@@ -5,6 +5,7 @@ import logging
import os import os
import pty import pty
import select import select
import signal
import struct import struct
import fcntl import fcntl
import time import time
@@ -152,16 +153,19 @@ class TerminalSession:
logger.info(f"resize() called for session {self.session_id}: {cols}x{rows}") logger.info(f"resize() called for session {self.session_id}: {cols}x{rows}")
self._set_terminal_size(cols, rows) self._set_terminal_size(cols, rows)
# Docker exec doesn't forward PTY resize to the container process, # Docker exec -it creates its own PTY inside the container,
# so we need to explicitly set the size inside the container shell. # so host PTY resize doesn't propagate to the container shell.
# Send on every resize so the container shell always matches the frontend. # Send SIGWINCH to the docker exec process on the host.
# Use stty -echo to prevent the command from being visible, then clear the line. # Docker exec forwards signals to the container process, which should
stty_cmd = ( # cause the container's shell to re-read its terminal size.
f"stty -echo; stty cols {cols} rows {rows}; stty echo\n" if self.process and self.process.pid:
f"\x1b[A\x1b[M" # Move up 1 line and delete it (clears the stty command) try:
).encode() os.kill(self.process.pid, signal.SIGWINCH)
await self.write_input(stty_cmd) logger.debug(f"Sent SIGWINCH to docker exec process {self.process.pid} for session {self.session_id}")
logger.debug(f"Sent stty resize to container for session {self.session_id}: {cols}x{rows}") except ProcessLookupError:
logger.warning(f"docker exec process {self.process.pid} not found for session {self.session_id}")
except Exception as e:
logger.warning(f"Failed to send SIGWINCH: {e}")
async def reset(self) -> None: async def reset(self) -> None:
"""Reset the session by killing the process and clearing state.""" """Reset the session by killing the process and clearing state."""
+29 -12
View File
@@ -115,6 +115,20 @@ export const TerminalComponent: React.FC<TerminalProps> = ({
if (msg.status === "connected") { if (msg.status === "connected") {
setStatus("connected"); setStatus("connected");
setError(null); setError(null);
// Clear terminal and refit after reset/reconnect
if (termRef.current) {
termRef.current.clear();
requestAnimationFrame(() => {
if (fitAddonRef.current && termRef.current) {
fitAddonRef.current.fit();
const { cols, rows } = termRef.current;
const currentWs = wsRef.current;
if (currentWs?.readyState === WebSocket.OPEN) {
currentWs.send(JSON.stringify({ type: "resize", cols, rows }));
}
}
});
}
} else if (msg.status === "resetting") { } else if (msg.status === "resetting") {
setStatus("resetting"); setStatus("resetting");
} }
@@ -210,11 +224,7 @@ export const TerminalComponent: React.FC<TerminalProps> = ({
const container = terminalRef.current; const container = terminalRef.current;
let ws: WebSocket; let ws: WebSocket;
// Open xterm immediately // Define fitTerminal before connectWebSocket so it's available in onmessage
term.open(container);
ws = connectWebSocket();
// Fit terminal and notify backend
const fitTerminal = () => { const fitTerminal = () => {
if (!fitAddonRef.current || !termRef.current) return; if (!fitAddonRef.current || !termRef.current) return;
const oldCols = termRef.current.cols; const oldCols = termRef.current.cols;
@@ -225,8 +235,9 @@ export const TerminalComponent: React.FC<TerminalProps> = ({
if (cols !== oldCols || rows !== oldRows) { if (cols !== oldCols || rows !== oldRows) {
termRef.current.refresh(0, rows - 1); termRef.current.refresh(0, rows - 1);
} }
if (ws.readyState === WebSocket.OPEN) { const currentWs = wsRef.current;
ws.send(JSON.stringify({ type: "resize", cols, rows })); if (currentWs?.readyState === WebSocket.OPEN) {
currentWs.send(JSON.stringify({ type: "resize", cols, rows }));
} }
}; };
@@ -237,6 +248,10 @@ export const TerminalComponent: React.FC<TerminalProps> = ({
}); });
}); });
// Open xterm immediately
term.open(container);
ws = connectWebSocket();
// Refit after font load (metrics may change) // Refit after font load (metrics may change)
document.fonts.ready.then(() => { document.fonts.ready.then(() => {
requestAnimationFrame(() => fitTerminal()); requestAnimationFrame(() => fitTerminal());
@@ -244,20 +259,21 @@ export const TerminalComponent: React.FC<TerminalProps> = ({
// Handle terminal input // Handle terminal input
term.onData((data) => { term.onData((data) => {
if (ws.readyState !== WebSocket.OPEN) return; const currentWs = wsRef.current;
if (currentWs?.readyState !== WebSocket.OPEN) return;
// Apply active modifier to single-character input // Apply active modifier to single-character input
const modifier = activeModifierRef.current; const modifier = activeModifierRef.current;
if (modifier && data.length === 1) { if (modifier && data.length === 1) {
const modified = applyModifierToChar(data, modifier); const modified = applyModifierToChar(data, modifier);
if (modified) { if (modified) {
ws.send(modified); currentWs.send(modified);
onModifierChange?.(null); onModifierChange?.(null);
return; return;
} }
} }
ws.send(data); currentWs.send(data);
}); });
// Handle container resize with ResizeObserver for accurate dimension tracking // Handle container resize with ResizeObserver for accurate dimension tracking
@@ -302,8 +318,9 @@ export const TerminalComponent: React.FC<TerminalProps> = ({
// Notify parent about terminal readiness // Notify parent about terminal readiness
if (onTerminalReadyRef.current) { if (onTerminalReadyRef.current) {
const sendData = (data: string) => { const sendData = (data: string) => {
if (ws.readyState === WebSocket.OPEN) { const currentWs = wsRef.current;
ws.send(data); if (currentWs?.readyState === WebSocket.OPEN) {
currentWs.send(data);
} }
}; };
const focusInput = () => { const focusInput = () => {