457 lines
15 KiB
Rust
457 lines
15 KiB
Rust
//! The four terminal use cases (ARCHITECTURE §6, L3).
|
|
|
|
use std::sync::Arc;
|
|
|
|
use domain::input::InputMediator;
|
|
use domain::mailbox::AgentMailbox;
|
|
use domain::ports::{CancelCurrentTurnOutcome, 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, StructuredSessions, TerminalSessions,
|
|
};
|
|
|
|
/// Result of cancelling the current request while preserving its structured session.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct CancelAgentChatOutput {
|
|
/// Stable session that remains registered and reusable.
|
|
pub session_id: SessionId,
|
|
/// Whether this call cancelled work or observed an already-terminal turn.
|
|
pub outcome: CancelCurrentTurnOutcome,
|
|
}
|
|
|
|
/// Releases exactly one mediated turn without allowing late terminal cleanup to
|
|
/// affect a successor that already owns the agent.
|
|
///
|
|
/// `cancel_head` and `mark_idle` are individually idempotent, while the ticket
|
|
/// checks fence Final/close/timeout/cancel races across successive turns.
|
|
pub fn release_turn_accounting(
|
|
input: &dyn InputMediator,
|
|
mailbox: &dyn AgentMailbox,
|
|
agent: domain::RuntimeAgentKey,
|
|
ticket: domain::mailbox::TicketId,
|
|
) -> bool {
|
|
if input.busy_state(agent).ticket() != Some(ticket) {
|
|
return false;
|
|
}
|
|
mailbox.cancel_head(agent, ticket);
|
|
if input.busy_state(agent).ticket() != Some(ticket) {
|
|
return false;
|
|
}
|
|
input.mark_idle(agent);
|
|
true
|
|
}
|
|
|
|
/// Cancels one structured chat turn without stopping its reusable runtime.
|
|
pub struct CancelAgentChat {
|
|
sessions: Arc<StructuredSessions>,
|
|
turn_accounting: Option<(Arc<dyn InputMediator>, Arc<dyn AgentMailbox>)>,
|
|
}
|
|
|
|
impl CancelAgentChat {
|
|
/// Builds the use case from the structured runtime registry.
|
|
#[must_use]
|
|
pub fn new(sessions: Arc<StructuredSessions>) -> Self {
|
|
Self {
|
|
sessions,
|
|
turn_accounting: None,
|
|
}
|
|
}
|
|
|
|
/// Wires the mediated busy/FIFO state owned by direct human turns.
|
|
#[must_use]
|
|
pub fn with_turn_accounting(
|
|
mut self,
|
|
input: Arc<dyn InputMediator>,
|
|
mailbox: Arc<dyn AgentMailbox>,
|
|
) -> Self {
|
|
self.turn_accounting = Some((input, mailbox));
|
|
self
|
|
}
|
|
|
|
/// Requests cancellation from the live session and deliberately leaves its
|
|
/// registry entry, conversation identity and runtime state untouched.
|
|
///
|
|
/// # Errors
|
|
/// Returns [`AppError::NotFound`] only when the session itself is absent, and
|
|
/// a process error when the adapter could not interrupt its current child.
|
|
pub async fn execute(&self, session_id: SessionId) -> Result<CancelAgentChatOutput, AppError> {
|
|
let (session, outcome, active_ticket) = self
|
|
.sessions
|
|
.request_turn_cancel(&session_id)
|
|
.ok_or_else(|| AppError::NotFound(format!("structured session {session_id}")))?;
|
|
if outcome == CancelCurrentTurnOutcome::Cancelled {
|
|
if let Err(error) = session.cancel_current_turn().await {
|
|
self.sessions.restore_turn_after_cancel_failure(&session_id);
|
|
return Err(AppError::from(error));
|
|
}
|
|
if let (Some((agent, ticket)), Some((input, mailbox))) =
|
|
(active_ticket, self.turn_accounting.as_ref())
|
|
{
|
|
release_turn_accounting(input.as_ref(), mailbox.as_ref(), agent, ticket);
|
|
}
|
|
}
|
|
Ok(CancelAgentChatOutput {
|
|
session_id,
|
|
outcome,
|
|
})
|
|
}
|
|
}
|
|
|
|
/// 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, have their current turn cancelled, then receive
|
|
/// `shutdown()` so relaunch guards stop seeing them as live during teardown and
|
|
/// no per-turn child survives it.
|
|
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 mut first_error = None;
|
|
let snapshots = live.live_agent_snapshots_for(project.id, &agent_id);
|
|
|
|
for snapshot in snapshots {
|
|
live.mark_runtime_state(snapshot, LiveRuntimeState::Stopping);
|
|
let removed = match snapshot.kind {
|
|
LiveSessionKind::Structured => {
|
|
if let Some(removed) = live.structured.remove_runtime(&snapshot.session_id) {
|
|
if let Err(error) = removed.session.cancel_current_turn().await {
|
|
first_error.get_or_insert_with(|| AppError::Process(error.to_string()));
|
|
}
|
|
if let Err(error) = removed.session.shutdown().await {
|
|
first_error.get_or_insert_with(|| AppError::Process(error.to_string()));
|
|
}
|
|
if let Err(error) = close
|
|
.release_managed_temp_handle(removed.managed_temp_handle)
|
|
.await
|
|
{
|
|
first_error.get_or_insert(error);
|
|
}
|
|
true
|
|
} else {
|
|
false
|
|
}
|
|
}
|
|
LiveSessionKind::Pty => {
|
|
if let Err(error) = close
|
|
.execute(CloseTerminalInput {
|
|
session_id: snapshot.session_id,
|
|
})
|
|
.await
|
|
{
|
|
first_error.get_or_insert(error);
|
|
false
|
|
} else {
|
|
true
|
|
}
|
|
}
|
|
};
|
|
if removed {
|
|
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,
|
|
});
|
|
}
|
|
}
|
|
match first_error {
|
|
Some(error) => Err(error),
|
|
None => 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())
|
|
}
|
|
}
|