Files
IdeA/crates/application/src/terminal/usecases.rs

347 lines
11 KiB
Rust

//! The four terminal use cases (ARCHITECTURE §6, L3).
use std::sync::Arc;
use domain::ports::{EventBus, PtyPort, SpawnSpec};
use domain::{
AgentId, DomainEvent, NodeId, Project, ProjectPath, PtySize, SessionId, SessionKind,
SessionStatus, TerminalSession,
};
use crate::error::AppError;
use crate::managed_temp::{ReleaseManagedTemp, ReleaseManagedTempInput};
use super::registry::{LiveRuntimeState, LiveSessionKind, LiveSessions, TerminalSessions};
/// Input for [`OpenTerminal::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OpenTerminalInput {
/// Working directory for the shell (absolute path; defaults applied by the
/// caller — typically the project root).
pub cwd: String,
/// Initial terminal height in rows.
pub rows: u16,
/// Initial terminal width in columns.
pub cols: u16,
/// Command to run. `None` ⇒ the platform default login shell.
pub command: Option<String>,
/// Arguments for the command.
pub args: Vec<String>,
/// The layout leaf hosting this session. `None` ⇒ a fresh node id (L4 will
/// thread the real layout node through here).
pub node_id: Option<NodeId>,
}
/// Output of [`OpenTerminal::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OpenTerminalOutput {
/// The created terminal session (its `id` is the [`SessionId`] minted by the
/// PTY layer and reused everywhere — write/resize/close, the output channel).
pub session: TerminalSession,
}
/// Opens a PTY in a cwd, creates a [`TerminalSession`], registers the handle.
pub struct OpenTerminal {
pty: Arc<dyn PtyPort>,
sessions: Arc<TerminalSessions>,
events: Arc<dyn EventBus>,
}
impl OpenTerminal {
/// Builds the use case from its injected ports/services.
#[must_use]
pub fn new(
pty: Arc<dyn PtyPort>,
sessions: Arc<TerminalSessions>,
events: Arc<dyn EventBus>,
) -> Self {
Self {
pty,
sessions,
events,
}
}
/// Executes the open: validate cwd + size, spawn the PTY, snapshot the
/// session, register the live handle, publish [`DomainEvent::LayoutChanged`].
///
/// # Errors
/// - [`AppError::Invalid`] for a non-absolute cwd or a zero-sized terminal,
/// - [`AppError::Process`] if the PTY fails to spawn.
pub async fn execute(&self, input: OpenTerminalInput) -> Result<OpenTerminalOutput, AppError> {
let cwd = ProjectPath::new(input.cwd).map_err(|e| AppError::Invalid(e.to_string()))?;
let size =
PtySize::new(input.rows, input.cols).map_err(|e| AppError::Invalid(e.to_string()))?;
let command = input.command.unwrap_or_else(default_shell);
let spec = SpawnSpec {
command,
args: input.args,
cwd: cwd.clone(),
env: Vec::new(),
context_plan: None,
sandbox: None,
};
// The PTY layer owns the session identity; we adopt the returned handle's
// id as the `TerminalSession.id` (single source of truth, ARCHITECTURE §4).
let handle = self.pty.spawn(spec, size).await?;
let session_id = handle.session_id;
let node_id = input.node_id.unwrap_or_else(NodeId::new_random);
let mut session =
TerminalSession::starting(session_id, node_id, cwd, SessionKind::Plain, size);
session.status = SessionStatus::Running;
self.sessions.insert(handle, session.clone());
// Output streaming + per-session Channel wiring happens in the presentation
// layer (it owns the transport). Announce so the UI can react.
self.events.publish(DomainEvent::PtyOutput {
session_id,
bytes: Vec::new(),
});
Ok(OpenTerminalOutput { session })
}
}
/// Input for [`WriteToTerminal::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WriteToTerminalInput {
/// Target session.
pub session_id: SessionId,
/// Bytes to write (typically keystrokes from xterm.js).
pub data: Vec<u8>,
}
/// Forwards bytes (keystrokes) to a live PTY.
pub struct WriteToTerminal {
pty: Arc<dyn PtyPort>,
sessions: Arc<TerminalSessions>,
}
impl WriteToTerminal {
/// Builds the use case.
#[must_use]
pub fn new(pty: Arc<dyn PtyPort>, sessions: Arc<TerminalSessions>) -> Self {
Self { pty, sessions }
}
/// Writes to the session's PTY.
///
/// # Errors
/// - [`AppError::NotFound`] if the session is unknown,
/// - [`AppError::Process`] on PTY I/O failure.
pub fn execute(&self, input: WriteToTerminalInput) -> Result<(), AppError> {
let handle = self
.sessions
.handle(&input.session_id)
.ok_or_else(|| AppError::NotFound(format!("terminal session {}", input.session_id)))?;
self.pty.write(&handle, &input.data)?;
Ok(())
}
}
/// Input for [`ResizeTerminal::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResizeTerminalInput {
/// Target session.
pub session_id: SessionId,
/// New height in rows.
pub rows: u16,
/// New width in columns.
pub cols: u16,
}
/// Resizes a live PTY.
pub struct ResizeTerminal {
pty: Arc<dyn PtyPort>,
sessions: Arc<TerminalSessions>,
}
impl ResizeTerminal {
/// Builds the use case.
#[must_use]
pub fn new(pty: Arc<dyn PtyPort>, sessions: Arc<TerminalSessions>) -> Self {
Self { pty, sessions }
}
/// Resizes the session's PTY.
///
/// # Errors
/// - [`AppError::Invalid`] for a zero-sized terminal,
/// - [`AppError::NotFound`] if the session is unknown,
/// - [`AppError::Process`] on PTY failure.
pub fn execute(&self, input: ResizeTerminalInput) -> Result<(), AppError> {
let size =
PtySize::new(input.rows, input.cols).map_err(|e| AppError::Invalid(e.to_string()))?;
let handle = self
.sessions
.handle(&input.session_id)
.ok_or_else(|| AppError::NotFound(format!("terminal session {}", input.session_id)))?;
self.pty.resize(&handle, size)?;
Ok(())
}
}
/// Input for [`CloseTerminal::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CloseTerminalInput {
/// Target session.
pub session_id: SessionId,
}
/// Output of [`CloseTerminal::execute`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CloseTerminalOutput {
/// Exit code reported by the killed process (`None` if signalled).
pub code: Option<i32>,
}
/// Kills a live PTY and forgets its handle.
pub struct CloseTerminal {
pty: Arc<dyn PtyPort>,
sessions: Arc<TerminalSessions>,
managed_temp_releaser: Option<Arc<ReleaseManagedTemp>>,
}
impl CloseTerminal {
/// Builds the use case.
#[must_use]
pub fn new(pty: Arc<dyn PtyPort>, sessions: Arc<TerminalSessions>) -> Self {
Self {
pty,
sessions,
managed_temp_releaser: None,
}
}
/// Injects the releaser for launch-scoped managed temp roots.
#[must_use]
pub fn with_managed_temp_releaser(mut self, releaser: Arc<ReleaseManagedTemp>) -> Self {
self.managed_temp_releaser = Some(releaser);
self
}
/// Releases a launch-scoped managed temp handle retained by a live session.
pub async fn release_managed_temp_handle(
&self,
handle: Option<String>,
) -> Result<(), AppError> {
let (Some(releaser), Some(handle)) = (&self.managed_temp_releaser, handle) else {
return Ok(());
};
releaser
.execute(ReleaseManagedTempInput { handle })
.await
.map(|_| ())
}
/// Kills the session's PTY and removes it from the registry. Idempotent on
/// the registry side (removing an unknown session is a no-op error).
///
/// # Errors
/// - [`AppError::NotFound`] if the session is unknown,
/// - [`AppError::Process`] if the kill fails.
pub async fn execute(
&self,
input: CloseTerminalInput,
) -> Result<CloseTerminalOutput, AppError> {
let handle = self
.sessions
.handle(&input.session_id)
.ok_or_else(|| AppError::NotFound(format!("terminal session {}", input.session_id)))?;
let status = self.pty.kill(&handle).await?;
let managed_temp_handle = self
.sessions
.remove_runtime(&input.session_id)
.and_then(|removed| removed.managed_temp_handle);
self.release_managed_temp_handle(managed_temp_handle)
.await?;
Ok(CloseTerminalOutput { code: status.code })
}
}
/// Runtime session stopped for one agent.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct StoppedAgentRuntime {
/// Agent whose live runtime was stopped.
pub agent_id: AgentId,
/// Live session id removed from its registry.
pub session_id: SessionId,
/// Last known layout node hosting the runtime view.
pub node_id: NodeId,
/// Runtime registry that owned the session.
pub kind: LiveSessionKind,
/// Lifecycle state reached by this stop operation.
pub state: LiveRuntimeState,
}
/// Stops one agent's live runtime across both registries.
///
/// PTY sessions are closed through [`CloseTerminal`]. Structured sessions are
/// removed from their registry before `shutdown()` so relaunch guards stop seeing
/// them as live even while shutdown runs.
pub async fn stop_live_agent_runtime_in_project(
project: &Project,
agent_id: AgentId,
live: &LiveSessions,
close: &CloseTerminal,
) -> Result<Vec<StoppedAgentRuntime>, AppError> {
let mut stopped = Vec::new();
let snapshots = live.live_agent_snapshots_for(project.id, &agent_id);
for snapshot in snapshots {
live.mark_runtime_state(snapshot, LiveRuntimeState::Stopping);
match snapshot.kind {
LiveSessionKind::Structured => {
if let Some(removed) = live.structured.remove_runtime(&snapshot.session_id) {
removed
.session
.shutdown()
.await
.map_err(|e| AppError::Process(e.to_string()))?;
close
.release_managed_temp_handle(removed.managed_temp_handle)
.await?;
}
}
LiveSessionKind::Pty => {
close
.execute(CloseTerminalInput {
session_id: snapshot.session_id,
})
.await?;
}
}
live.mark_runtime_state(snapshot, LiveRuntimeState::Stopped);
stopped.push(StoppedAgentRuntime {
agent_id: snapshot.agent_id,
session_id: snapshot.session_id,
node_id: snapshot.node_id,
kind: snapshot.kind,
state: LiveRuntimeState::Stopped,
});
}
Ok(stopped)
}
/// The platform default interactive shell.
///
/// Resolution policy lives in the application layer (a metier default), not the
/// adapter, so it is uniform and testable. On Unix we honour `$SHELL`, falling
/// back to `/bin/sh`; on Windows we use `cmd.exe` (a ConPTY spike point — PowerShell
/// could become the default, ARCHITECTURE §13.1).
fn default_shell() -> String {
#[cfg(windows)]
{
std::env::var("COMSPEC").unwrap_or_else(|_| "cmd.exe".to_owned())
}
#[cfg(not(windows))]
{
std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_owned())
}
}