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>
This commit is contained in:
177
crates/application/src/workstate/actions.rs
Normal file
177
crates/application/src/workstate/actions.rs
Normal file
@ -0,0 +1,177 @@
|
||||
//! 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
|
||||
)))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user