Files
IdeA/crates/application/src/workstate/actions.rs
Blomios 3408c96974 feat(workstate): actions contrôlées sur le work-state (Lot D backend)
Ajoute un module d'actions contrôlées (`workstate/actions.rs`) côté application
et l'expose via des commandes Tauri : DTO camelCase, câblage commands/state/lib.
Les actions valident leurs invariants avant d'agir sur le read-model.

- application : use cases d'actions contrôlées + intégration au work-state.
- app-tauri : commandes dédiées, DTO et enregistrement dans le handler.

Tests verts : application workstate_actions (7), workstate (21),
app-tauri dto_agents (25), list_live_agents_r0b (5), cargo check OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 19:51:46 +02:00

178 lines
6.5 KiB
Rust

//! Agent-level controlled actions over live sessions (Lot D).
//!
//! Companion to the read-only work-state model: these use cases let the UI drive
//! an **already-running** agent's live session by **agent id**, never by spawning a
//! new process. [`AttachLiveAgent`] rebinds the hosting cell ("a cell is a view");
//! [`StopLiveAgent`] tears the live session down polymorphically (PTY kill vs
//! structured shutdown). Neither touches the agent entity, its tickets, the
//! conversation summaries, the handoff or the provider sessions.
use std::sync::Arc;
use domain::{AgentId, NodeId, Project, SessionId};
use crate::error::AppError;
use crate::terminal::{CloseTerminal, CloseTerminalInput, LiveSessionKind, LiveSessions};
/// Input for [`AttachLiveAgent::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AttachLiveAgentInput {
/// The owning project (boundary/symmetry; the registries are global by agent).
pub project: Project,
/// The already-running agent whose live session is rebound.
pub agent_id: AgentId,
/// The layout leaf that should host the live session view.
pub node_id: NodeId,
}
/// Output of [`AttachLiveAgent::execute`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AttachLiveAgentOutput {
/// The rebound agent.
pub agent_id: AgentId,
/// The node now hosting the session view.
pub node_id: NodeId,
/// The unchanged live session id.
pub session_id: SessionId,
/// Runtime family that owns the session.
pub kind: LiveSessionKind,
}
/// Rebinds an already-running agent session to a visible layout cell, never
/// spawning a process ("a cell is a view").
///
/// Resolves the live session by agent in the PTY registry first, then the
/// structured one, and updates only the hosting `node_id` (the PTY handle / agent
/// session, its id and scrollback stay intact). Idempotent when the agent is
/// already attached to the same node (the rebind is a no-op that returns the same
/// session). No log/handoff/provider-session is touched.
pub struct AttachLiveAgent {
live: Arc<LiveSessions>,
}
impl AttachLiveAgent {
/// Builds the use case from the aggregated live-session registry.
#[must_use]
pub fn new(live: Arc<LiveSessions>) -> Self {
Self { live }
}
/// Rebinds the agent's live session onto `node_id`.
///
/// # Errors
/// [`AppError::NotFound`] when the agent has no live session in either registry.
pub fn execute(&self, input: AttachLiveAgentInput) -> Result<AttachLiveAgentOutput, AppError> {
// PTY first, then structured (one-live-session-per-agent ⇒ at most one match).
if let Some(session) = self
.live
.pty
.rebind_agent_node(&input.agent_id, input.node_id)
{
return Ok(AttachLiveAgentOutput {
agent_id: input.agent_id,
node_id: session.node_id,
session_id: session.id,
kind: LiveSessionKind::Pty,
});
}
if let Some(session) = self
.live
.structured
.rebind_agent_node(&input.agent_id, input.node_id)
{
return Ok(AttachLiveAgentOutput {
agent_id: input.agent_id,
node_id: input.node_id,
session_id: session.id(),
kind: LiveSessionKind::Structured,
});
}
Err(AppError::NotFound(format!(
"running session for agent {}",
input.agent_id
)))
}
}
/// Input for [`StopLiveAgent::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StopLiveAgentInput {
/// The owning project (boundary/symmetry; the registries are global by agent).
pub project: Project,
/// The agent whose live session is stopped.
pub agent_id: AgentId,
}
/// Output of [`StopLiveAgent::execute`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct StopLiveAgentOutput {
/// The stopped agent.
pub agent_id: AgentId,
/// The id of the session that was torn down.
pub session_id: SessionId,
/// Runtime family that owned the session.
pub kind: LiveSessionKind,
}
/// Stops an already-running agent's live session, polymorphically.
///
/// Resolves the session by agent (PTY first, then structured). A PTY session is
/// torn down through the existing [`CloseTerminal`] primitive (registry removal +
/// process kill); a structured session is removed then `shutdown()`. The agent
/// entity, its tickets, conversation summaries and handoff are **not** removed. Any
/// exit event is left to the existing infrastructure (the PTY pump / session
/// backend) — no bus is invented here.
pub struct StopLiveAgent {
live: Arc<LiveSessions>,
close: Arc<CloseTerminal>,
}
impl StopLiveAgent {
/// Builds the use case from the live-session registry and the close primitive.
#[must_use]
pub fn new(live: Arc<LiveSessions>, close: Arc<CloseTerminal>) -> Self {
Self { live, close }
}
/// Tears down the agent's live session.
///
/// # Errors
/// - [`AppError::NotFound`] when the agent has no live session,
/// - [`AppError::Process`] when the PTY kill or structured shutdown fails.
pub async fn execute(
&self,
input: StopLiveAgentInput,
) -> Result<StopLiveAgentOutput, AppError> {
// PTY first: delegate to the existing close primitive (removes + kills).
if let Some(session_id) = self.live.pty.session_for_agent(&input.agent_id) {
self.close
.execute(CloseTerminalInput { session_id })
.await?;
return Ok(StopLiveAgentOutput {
agent_id: input.agent_id,
session_id,
kind: LiveSessionKind::Pty,
});
}
// Structured: remove from the registry first (so the uniqueness guard no
// longer sees a live session), then shut the session down out of the lock.
if let Some(session_id) = self.live.structured.session_id_for_agent(&input.agent_id) {
if let Some(session) = self.live.structured.remove(&session_id) {
session
.shutdown()
.await
.map_err(|e| AppError::Process(e.to_string()))?;
}
return Ok(StopLiveAgentOutput {
agent_id: input.agent_id,
session_id,
kind: LiveSessionKind::Structured,
});
}
Err(AppError::NotFound(format!(
"running session for agent {}",
input.agent_id
)))
}
}