From 3408c96974fd757d17433563819b8f441335d3c9 Mon Sep 17 00:00:00 2001 From: Blomios Date: Sat, 20 Jun 2026 19:51:46 +0200 Subject: [PATCH] =?UTF-8?q?feat(workstate):=20actions=20contr=C3=B4l=C3=A9?= =?UTF-8?q?es=20sur=20le=20work-state=20(Lot=20D=20backend)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- crates/app-tauri/src/commands.rs | 84 +++-- crates/app-tauri/src/dto.rs | 66 +++- crates/app-tauri/src/lib.rs | 1 + crates/app-tauri/src/state.rs | 47 ++- crates/app-tauri/tests/dto_agents.rs | 79 ++++ crates/application/src/lib.rs | 7 +- crates/application/src/workstate/actions.rs | 177 +++++++++ crates/application/src/workstate/mod.rs | 7 + crates/application/tests/workstate_actions.rs | 351 ++++++++++++++++++ 9 files changed, 766 insertions(+), 53 deletions(-) create mode 100644 crates/application/src/workstate/actions.rs create mode 100644 crates/application/tests/workstate_actions.rs diff --git a/crates/app-tauri/src/commands.rs b/crates/app-tauri/src/commands.rs index 35b0a73..60314c4 100644 --- a/crates/app-tauri/src/commands.rs +++ b/crates/app-tauri/src/commands.rs @@ -9,19 +9,20 @@ use tauri::State; use crate::dto::DismissEmbedderSuggestionRequestDto; use application::{ - AppError, AssignSkillToAgentInput, ChangeAgentProfileInput, CloseProjectInput, - CreateAgentInput, CreateLayoutInput, CreateMemoryInput, CreateSkillInput, DeleteAgentInput, - DeleteEmbedderProfileInput, DeleteLayoutInput, DeleteMemoryInput, DeleteSkillInput, - DeleteTemplateInput, DetectAgentDriftInput, GetMemoryInput, GetProjectWorkStateInput, - GitBranchesInput, GitCheckoutInput, GitCommitInput, GitGraphInput, GitInitInput, GitLogInput, - GitStagePathInput, GitStatusInput, InspectConversationInput, LaunchAgentInput, ListAgentsInput, - ListLayoutsInput, ListMemoriesInput, ListResumableAgentsInput, ListSkillsInput, LiveSessions, - LoadLayoutInput, McpRuntime, MutateLayoutInput, OpenProjectInput, ReadAgentContextInput, - ReadMemoryIndexInput, ReadProjectContextInput, RecallMemoryInput, ReconcileLayoutsInput, - RenameLayoutInput, ResolveAgentPermissionsInput, ResolveMemoryLinksInput, SetActiveLayoutInput, - SnapshotRunningAgentsInput, SyncAgentWithTemplateInput, UnassignSkillFromAgentInput, - UpdateAgentContextInput, UpdateAgentPermissionsInput, UpdateMemoryInput, - UpdateProjectContextInput, UpdateProjectPermissionsInput, UpdateSkillInput, + AppError, AssignSkillToAgentInput, AttachLiveAgentInput, ChangeAgentProfileInput, + CloseProjectInput, CreateAgentInput, CreateLayoutInput, CreateMemoryInput, CreateSkillInput, + DeleteAgentInput, DeleteEmbedderProfileInput, DeleteLayoutInput, DeleteMemoryInput, + DeleteSkillInput, DeleteTemplateInput, DetectAgentDriftInput, GetMemoryInput, + GetProjectWorkStateInput, GitBranchesInput, GitCheckoutInput, GitCommitInput, GitGraphInput, + GitInitInput, GitLogInput, GitStagePathInput, GitStatusInput, InspectConversationInput, + LaunchAgentInput, ListAgentsInput, ListLayoutsInput, ListMemoriesInput, + ListResumableAgentsInput, ListSkillsInput, LiveSessions, LoadLayoutInput, McpRuntime, + MutateLayoutInput, OpenProjectInput, ReadAgentContextInput, ReadMemoryIndexInput, + ReadProjectContextInput, RecallMemoryInput, ReconcileLayoutsInput, RenameLayoutInput, + ResolveAgentPermissionsInput, ResolveMemoryLinksInput, SetActiveLayoutInput, + SnapshotRunningAgentsInput, StopLiveAgentInput, SyncAgentWithTemplateInput, + UnassignSkillFromAgentInput, UpdateAgentContextInput, UpdateAgentPermissionsInput, + UpdateMemoryInput, UpdateProjectContextInput, UpdateProjectPermissionsInput, UpdateSkillInput, }; use domain::ports::PtyHandle; @@ -29,11 +30,11 @@ use crate::dto::{ parse_agent_id, parse_close_terminal, parse_delete_profile, parse_layout_id, parse_memory_slug, parse_node_id, parse_profile_id, parse_project_id, parse_session_id, parse_skill_id, parse_template_id, parse_ticket_id, AgentDriftListDto, AgentDto, AgentListDto, - AssignSkillRequestDto, AttachLiveAgentRequestDto, ChangeAgentProfileDto, - ChangeAgentProfileRequestDto, ConfigureProfilesRequestDto, ConversationDetailsDto, - CreateAgentFromTemplateRequestDto, CreateAgentRequestDto, CreateLayoutRequestDto, - CreateLayoutResultDto, CreateMemoryRequestDto, CreateProjectRequestDto, CreateSkillRequestDto, - CreateTemplateRequestDto, DeleteLayoutRequestDto, DeleteLayoutResultDto, + AssignSkillRequestDto, AttachLiveAgentRequestDto, AttachLiveAgentResponseDto, + ChangeAgentProfileDto, ChangeAgentProfileRequestDto, ConfigureProfilesRequestDto, + ConversationDetailsDto, CreateAgentFromTemplateRequestDto, CreateAgentRequestDto, + CreateLayoutRequestDto, CreateLayoutResultDto, CreateMemoryRequestDto, CreateProjectRequestDto, + CreateSkillRequestDto, CreateTemplateRequestDto, DeleteLayoutRequestDto, DeleteLayoutResultDto, DeliveredDelegationRequestDto, DetectProfilesRequestDto, DetectProfilesResponseDto, EffectivePermissionsDto, EmbedderEnginesDto, EmbedderProfileDto, EmbedderProfileListDto, ErrorDto, FirstRunStateDto, FrontAttachedRequestDto, GitBranchesDto, GitCheckoutRequestDto, @@ -46,7 +47,8 @@ use crate::dto::{ ReattachResultDto, RecallMemoryRequestDto, RenameLayoutRequestDto, ReplyChunk, ResizeTerminalRequestDto, ResolveAgentPermissionsRequestDto, ResumableAgentListDto, SaveEmbedderProfileRequestDto, SaveProfileRequestDto, SetActiveLayoutRequestDto, SkillDto, - SkillListDto, SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto, TemplateListDto, + SkillListDto, StopLiveAgentRequestDto, StopLiveAgentResponseDto, + SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto, TemplateListDto, TerminalClosedDto, TerminalSessionDto, UnassignSkillRequestDto, UpdateAgentContextRequestDto, UpdateAgentPermissionsRequestDto, UpdateMemoryRequestDto, UpdateProjectContextRequestDto, UpdateProjectPermissionsRequestDto, UpdateSkillRequestDto, UpdateTemplateRequestDto, @@ -1036,20 +1038,42 @@ pub fn list_live_agents( pub async fn attach_live_agent( request: AttachLiveAgentRequestDto, state: State<'_, AppState>, -) -> Result { - let _project = resolve_project(&request.project_id, &state).await?; +) -> Result { + let project = resolve_project(&request.project_id, &state).await?; let agent_id = parse_agent_id(&request.agent_id)?; let node_id = parse_node_id(&request.node_id)?; - let session = state - .terminal_sessions - .rebind_agent_node(&agent_id, node_id) - .ok_or_else(|| AppError::NotFound(format!("running session for agent {agent_id}")))?; + state + .attach_live_agent + .execute(AttachLiveAgentInput { + project, + agent_id, + node_id, + }) + .map(AttachLiveAgentResponseDto::from) + .map_err(ErrorDto::from) +} - Ok(LiveAgentListDto::from_pairs(vec![( - agent_id, - session.node_id, - session.id, - )])) +/// `stop_live_agent` — tear down an already-running agent's live session by agent +/// id, polymorphically (PTY kill or structured shutdown), without removing the +/// agent, its tickets, conversation summary or handoff. +/// +/// # Errors +/// Returns an [`ErrorDto`] (`INVALID` for malformed ids, `NOT_FOUND` if the +/// project/agent is unknown or the agent has no live session, `PROCESS` on a +/// kill/shutdown failure). +#[tauri::command] +pub async fn stop_live_agent( + request: StopLiveAgentRequestDto, + state: State<'_, AppState>, +) -> Result { + let project = resolve_project(&request.project_id, &state).await?; + let agent_id = parse_agent_id(&request.agent_id)?; + state + .stop_live_agent + .execute(StopLiveAgentInput { project, agent_id }) + .await + .map(StopLiveAgentResponseDto::from) + .map_err(ErrorDto::from) } /// `read_agent_context` — read an agent's Markdown context. diff --git a/crates/app-tauri/src/dto.rs b/crates/app-tauri/src/dto.rs index 654e439..4d0a05e 100644 --- a/crates/app-tauri/src/dto.rs +++ b/crates/app-tauri/src/dto.rs @@ -9,10 +9,10 @@ use serde::{Deserialize, Serialize}; use application::{ - AgentTicketState, AppError, ConversationPreviewStatus, ConversationTurnWorkPreview, - ConversationWorkSummary, CreateProjectInput, CreateProjectOutput, GitGraphOutput, HealthInput, - HealthReport, LayoutKind, ListProjectsOutput, LiveSessionKind, OpenProjectOutput, - ProjectWorkState, TicketWorkSource, TicketWorkStatus, + AgentTicketState, AppError, AttachLiveAgentOutput, ConversationPreviewStatus, + ConversationTurnWorkPreview, ConversationWorkSummary, CreateProjectInput, CreateProjectOutput, + GitGraphOutput, HealthInput, HealthReport, LayoutKind, ListProjectsOutput, LiveSessionKind, + OpenProjectOutput, ProjectWorkState, StopLiveAgentOutput, TicketWorkSource, TicketWorkStatus, }; use domain::{AgentBusyState, Project, ProjectId, TurnRole}; @@ -1751,6 +1751,64 @@ pub struct AttachLiveAgentRequestDto { pub node_id: String, } +/// Response DTO for `attach_live_agent`: the rebound live session's coordinates. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AttachLiveAgentResponseDto { + /// The rebound agent's id. + pub agent_id: String, + /// The node now hosting the session view. + pub node_id: String, + /// The (unchanged) live session id. + pub session_id: String, + /// Runtime family that owns the session (`pty`/`structured`). + pub kind: LiveWorkSessionKindDto, +} + +impl From for AttachLiveAgentResponseDto { + fn from(out: AttachLiveAgentOutput) -> Self { + Self { + agent_id: out.agent_id.to_string(), + node_id: out.node_id.to_string(), + session_id: out.session_id.to_string(), + kind: out.kind.into(), + } + } +} + +/// Request DTO for `stop_live_agent`: tear down an already-running agent's live +/// session by agent id (no spawn, agent entity preserved). +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct StopLiveAgentRequestDto { + /// Id of the owning project (validated for symmetry and future scoping). + pub project_id: String, + /// Id of the running agent to stop. + pub agent_id: String, +} + +/// Response DTO for `stop_live_agent`: the torn-down session's coordinates. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct StopLiveAgentResponseDto { + /// The stopped agent's id. + pub agent_id: String, + /// The id of the session that was torn down. + pub session_id: String, + /// Runtime family that owned the session (`pty`/`structured`). + pub kind: LiveWorkSessionKindDto, +} + +impl From for StopLiveAgentResponseDto { + fn from(out: StopLiveAgentOutput) -> Self { + Self { + agent_id: out.agent_id.to_string(), + session_id: out.session_id.to_string(), + kind: out.kind.into(), + } + } +} + /// Parses an agent-id string (UUID) coming from the frontend. /// /// # Errors diff --git a/crates/app-tauri/src/lib.rs b/crates/app-tauri/src/lib.rs index 6e22a42..d541b4f 100644 --- a/crates/app-tauri/src/lib.rs +++ b/crates/app-tauri/src/lib.rs @@ -166,6 +166,7 @@ pub fn run() { commands::get_project_work_state, commands::list_live_agents, commands::attach_live_agent, + commands::stop_live_agent, commands::read_agent_context, commands::update_agent_context, commands::delete_agent, diff --git a/crates/app-tauri/src/state.rs b/crates/app-tauri/src/state.rs index 63af650..1d52a14 100644 --- a/crates/app-tauri/src/state.rs +++ b/crates/app-tauri/src/state.rs @@ -12,22 +12,23 @@ use std::path::PathBuf; use std::sync::{Arc, Mutex}; use application::{ - AgentResumer, AppError, AssignSkillToAgent, ChangeAgentProfile, CheckEmbedderSuggestion, - CloseProject, CloseTab, CloseTerminal, ConfigureProfiles, ContextGuardUseCases, - CreateAgentFromScratch, CreateAgentFromTemplate, CreateLayout, CreateMemory, CreateProject, - CreateSkill, CreateTemplate, DeleteAgent, DeleteEmbedderProfile, DeleteLayout, DeleteMemory, - DeleteProfile, DeleteSkill, DeleteTemplate, DescribeEmbedderEngines, DetectAgentDrift, - DetectProfiles, DismissEmbedderSuggestion, FirstRunState, GetMemory, GetProjectPermissions, - GetProjectWorkState, GitBranches, GitCheckout, GitCommit, GitGraph, GitInit, GitLog, GitStage, - GitStatus, GitUnstage, HealthUseCase, InspectConversation, LaunchAgent, LaunchAgentInput, - ListAgents, ListAgentsInput, ListEmbedderProfiles, ListLayouts, ListMemories, ListProfiles, - ListProjects, ListResumableAgents, ListSkills, ListTemplates, LiveAgentRegistry, LiveSessions, - LoadLayout, McpRuntime, MoveTabToNewWindow, MutateLayout, OnnxModelView, OpenProject, - OpenTerminal, OrchestratorService, PermissionProjectorRegistry, ProposeContext, - ReadAgentContext, ReadContext, ReadMemory, ReadMemoryIndex, ReadProjectContext, RecallMemory, - ReconcileLayouts, RecordTurn, RecordTurnProvider, ReferenceProfiles, RenameLayout, - ResizeTerminal, ResolveAgentPermissions, ResolveMemoryLinks, SaveEmbedderProfile, SaveProfile, - SessionLimitService, SetActiveLayout, SnapshotRunningAgents, StructuredSessions, + AgentResumer, AppError, AssignSkillToAgent, AttachLiveAgent, ChangeAgentProfile, + CheckEmbedderSuggestion, CloseProject, CloseTab, CloseTerminal, ConfigureProfiles, + ContextGuardUseCases, CreateAgentFromScratch, CreateAgentFromTemplate, CreateLayout, + CreateMemory, CreateProject, CreateSkill, CreateTemplate, DeleteAgent, DeleteEmbedderProfile, + DeleteLayout, DeleteMemory, DeleteProfile, DeleteSkill, DeleteTemplate, + DescribeEmbedderEngines, DetectAgentDrift, DetectProfiles, DismissEmbedderSuggestion, + FirstRunState, GetMemory, GetProjectPermissions, GetProjectWorkState, GitBranches, GitCheckout, + GitCommit, GitGraph, GitInit, GitLog, GitStage, GitStatus, GitUnstage, HealthUseCase, + InspectConversation, LaunchAgent, LaunchAgentInput, ListAgents, ListAgentsInput, + ListEmbedderProfiles, ListLayouts, ListMemories, ListProfiles, ListProjects, + ListResumableAgents, ListSkills, ListTemplates, LiveAgentRegistry, LiveSessions, LoadLayout, + McpRuntime, MoveTabToNewWindow, MutateLayout, OnnxModelView, OpenProject, OpenTerminal, + OrchestratorService, PermissionProjectorRegistry, ProposeContext, ReadAgentContext, + ReadContext, ReadMemory, ReadMemoryIndex, ReadProjectContext, RecallMemory, ReconcileLayouts, + RecordTurn, RecordTurnProvider, ReferenceProfiles, RenameLayout, ResizeTerminal, + ResolveAgentPermissions, ResolveMemoryLinks, SaveEmbedderProfile, SaveProfile, + SessionLimitService, SetActiveLayout, SnapshotRunningAgents, StopLiveAgent, StructuredSessions, SuggestedThisSession, SyncAgentWithTemplate, TerminalSessions, UnassignSkillFromAgent, UpdateAgentContext, UpdateAgentPermissions, UpdateMemory, UpdateProjectContext, UpdateProjectPermissions, UpdateSkill, UpdateTemplate, WriteMemory, WriteToTerminal, @@ -351,6 +352,10 @@ pub struct AppState { pub list_resumable_agents: Arc, /// Read-only live/busy state for the project's manifest agents. pub get_project_work_state: Arc, + /// Rebinds an already-running agent's live session to a visible cell (Lot D). + pub attach_live_agent: Arc, + /// Tears down an already-running agent's live session by agent id (Lot D). + pub stop_live_agent: Arc, /// Best-effort inspection of a conversation (last topic + token indicator) /// for the resume popup (T7). Optional/extensible: backed by a `Vec` of /// [`domain::ports::SessionInspector`]s; an empty/missing match yields empty @@ -1066,6 +1071,14 @@ impl AppState { as Arc, ), ); + // Lot D — actions contrôlées agent-level sur les sessions vivantes : attach + // (rebind de la cellule-vue, zéro spawn) et stop (kill PTY via la primitive + // `CloseTerminal` existante / shutdown structuré). Aucune création de session. + let attach_live_agent = Arc::new(AttachLiveAgent::new(Arc::clone(&live_sessions))); + let stop_live_agent = Arc::new(StopLiveAgent::new( + Arc::clone(&live_sessions), + Arc::clone(&close_terminal), + )); // --- Limites de session des agents (ARCHITECTURE §21, LS7) --- // Service pur-ports « détecter → planifier → reprendre » câblé sur l'existant : @@ -1233,6 +1246,8 @@ impl AppState { change_agent_profile, list_resumable_agents, get_project_work_state, + attach_live_agent, + stop_live_agent, inspect_conversation, project_store, get_project_permissions, diff --git a/crates/app-tauri/tests/dto_agents.rs b/crates/app-tauri/tests/dto_agents.rs index 4532582..6d1d2d9 100644 --- a/crates/app-tauri/tests/dto_agents.rs +++ b/crates/app-tauri/tests/dto_agents.rs @@ -552,3 +552,82 @@ fn launch_agent_output_propagates_assigned_conversation_id() { "no snake_case leak" ); } + +// --------------------------------------------------------------------------- +// Lot D — attach/stop live agent DTOs (controlled actions) +// --------------------------------------------------------------------------- + +#[test] +fn attach_live_agent_request_deserialises_camelcase() { + use app_tauri_lib::dto::AttachLiveAgentRequestDto; + + let raw = json!({ + "projectId": Uuid::from_u128(1).to_string(), + "agentId": Uuid::from_u128(2).to_string(), + "nodeId": Uuid::from_u128(3).to_string() + }); + let dto: AttachLiveAgentRequestDto = serde_json::from_value(raw).unwrap(); + assert_eq!(dto.project_id, Uuid::from_u128(1).to_string()); + assert_eq!(dto.agent_id, Uuid::from_u128(2).to_string()); + assert_eq!(dto.node_id, Uuid::from_u128(3).to_string()); +} + +#[test] +fn attach_live_agent_response_serialises_camelcase_with_kind() { + use app_tauri_lib::dto::AttachLiveAgentResponseDto; + use application::{AttachLiveAgentOutput, LiveSessionKind}; + + let agent = AgentId::from_uuid(Uuid::from_u128(11)); + let node = NodeId::from_uuid(Uuid::from_u128(12)); + let session = SessionId::from_uuid(Uuid::from_u128(13)); + let dto = AttachLiveAgentResponseDto::from(AttachLiveAgentOutput { + agent_id: agent, + node_id: node, + session_id: session, + kind: LiveSessionKind::Pty, + }); + + let v = serde_json::to_value(&dto).unwrap(); + assert_eq!(v["agentId"], agent.to_string()); + assert_eq!(v["nodeId"], node.to_string()); + assert_eq!(v["sessionId"], session.to_string()); + assert_eq!(v["kind"], "pty"); + // No snake_case leak. + assert!(v.get("agent_id").is_none()); + assert!(v.get("session_id").is_none()); +} + +#[test] +fn stop_live_agent_request_deserialises_camelcase() { + use app_tauri_lib::dto::StopLiveAgentRequestDto; + + let raw = json!({ + "projectId": Uuid::from_u128(1).to_string(), + "agentId": Uuid::from_u128(2).to_string() + }); + let dto: StopLiveAgentRequestDto = serde_json::from_value(raw).unwrap(); + assert_eq!(dto.project_id, Uuid::from_u128(1).to_string()); + assert_eq!(dto.agent_id, Uuid::from_u128(2).to_string()); +} + +#[test] +fn stop_live_agent_response_serialises_camelcase_with_kind() { + use app_tauri_lib::dto::StopLiveAgentResponseDto; + use application::{LiveSessionKind, StopLiveAgentOutput}; + + let agent = AgentId::from_uuid(Uuid::from_u128(11)); + let session = SessionId::from_uuid(Uuid::from_u128(13)); + let dto = StopLiveAgentResponseDto::from(StopLiveAgentOutput { + agent_id: agent, + session_id: session, + kind: LiveSessionKind::Structured, + }); + + let v = serde_json::to_value(&dto).unwrap(); + assert_eq!(v["agentId"], agent.to_string()); + assert_eq!(v["sessionId"], session.to_string()); + assert_eq!(v["kind"], "structured"); + // Stop response carries no node id (the session is gone). + assert!(v.get("nodeId").is_none()); + assert!(v.get("session_id").is_none()); +} diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs index 4e40b5c..f72b0d3 100644 --- a/crates/application/src/lib.rs +++ b/crates/application/src/lib.rs @@ -119,8 +119,9 @@ pub use terminal::{ }; pub use window::{MoveTabToNewWindow, MoveTabToNewWindowInput, MoveTabToNewWindowOutput}; pub use workstate::{ - AgentTicketState, AgentWorkState, ConversationLogProvider, ConversationPreviewStatus, - ConversationTurnWorkPreview, ConversationWorkSummary, GetProjectWorkState, - GetProjectWorkStateInput, LiveWorkSession, ProjectWorkState, TicketWorkSource, + AgentTicketState, AgentWorkState, AttachLiveAgent, AttachLiveAgentInput, AttachLiveAgentOutput, + ConversationLogProvider, ConversationPreviewStatus, ConversationTurnWorkPreview, + ConversationWorkSummary, GetProjectWorkState, GetProjectWorkStateInput, LiveWorkSession, + ProjectWorkState, StopLiveAgent, StopLiveAgentInput, StopLiveAgentOutput, TicketWorkSource, TicketWorkStatus, }; diff --git a/crates/application/src/workstate/actions.rs b/crates/application/src/workstate/actions.rs new file mode 100644 index 0000000..ca44db2 --- /dev/null +++ b/crates/application/src/workstate/actions.rs @@ -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, +} + +impl AttachLiveAgent { + /// Builds the use case from the aggregated live-session registry. + #[must_use] + pub fn new(live: Arc) -> 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 { + // 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, + close: Arc, +} + +impl StopLiveAgent { + /// Builds the use case from the live-session registry and the close primitive. + #[must_use] + pub fn new(live: Arc, close: Arc) -> 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 { + // 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 + ))) + } +} diff --git a/crates/application/src/workstate/mod.rs b/crates/application/src/workstate/mod.rs index a7ea42f..2e70414 100644 --- a/crates/application/src/workstate/mod.rs +++ b/crates/application/src/workstate/mod.rs @@ -5,6 +5,13 @@ //! beyond loading the manifest through the existing context store and creates no //! durable projection. +mod actions; + +pub use actions::{ + AttachLiveAgent, AttachLiveAgentInput, AttachLiveAgentOutput, StopLiveAgent, + StopLiveAgentInput, StopLiveAgentOutput, +}; + use std::collections::{HashMap, HashSet}; use std::sync::Arc; diff --git a/crates/application/tests/workstate_actions.rs b/crates/application/tests/workstate_actions.rs new file mode 100644 index 0000000..565526b --- /dev/null +++ b/crates/application/tests/workstate_actions.rs @@ -0,0 +1,351 @@ +//! Tests for the Lot D agent-level controlled actions (`AttachLiveAgent`, +//! `StopLiveAgent`) over the live-session registries. +//! +//! Every port is faked in-memory so the use cases run without a real PTY or +//! agent-session backend: [`FakePty`] records `kill`s, [`FakeSession`] records +//! `shutdown`s. + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; + +use async_trait::async_trait; + +use application::{ + AttachLiveAgent, AttachLiveAgentInput, CloseTerminal, LiveSessionKind, LiveSessions, + StopLiveAgent, StopLiveAgentInput, StructuredSessions, TerminalSessions, +}; +use domain::ports::{ + AgentSession, AgentSessionError, ExitStatus, OutputStream, PtyError, PtyHandle, PtyPort, + ReplyStream, SpawnSpec, +}; +use domain::Project; +use domain::{ + AgentId, NodeId, ProjectId, ProjectPath, PtySize, RemoteRef, SessionId, SessionKind, + TerminalSession, +}; +use uuid::Uuid; + +// --------------------------------------------------------------------------- +// ids / fixtures +// --------------------------------------------------------------------------- + +fn aid(n: u128) -> AgentId { + AgentId::from_uuid(Uuid::from_u128(n)) +} + +fn sid(n: u128) -> SessionId { + SessionId::from_uuid(Uuid::from_u128(n)) +} + +fn nid(n: u128) -> NodeId { + NodeId::from_uuid(Uuid::from_u128(n)) +} + +fn project() -> Project { + Project::new( + ProjectId::from_uuid(Uuid::from_u128(1)), + "demo", + ProjectPath::new("/tmp/idea-workstate-actions").unwrap(), + RemoteRef::local(), + 1_700_000_000_000, + ) + .unwrap() +} + +// --------------------------------------------------------------------------- +// Fakes +// --------------------------------------------------------------------------- + +/// A recording [`PtyPort`] whose `kill` records the killed session id. +#[derive(Default)] +struct FakePty { + kills: Mutex>, +} + +impl FakePty { + fn kills(&self) -> Vec { + self.kills.lock().unwrap().clone() + } +} + +#[async_trait] +impl PtyPort for FakePty { + async fn spawn(&self, _spec: SpawnSpec, _size: PtySize) -> Result { + unreachable!("Lot D never spawns") + } + fn write(&self, _handle: &PtyHandle, _data: &[u8]) -> Result<(), PtyError> { + Ok(()) + } + fn resize(&self, _handle: &PtyHandle, _size: PtySize) -> Result<(), PtyError> { + Ok(()) + } + fn subscribe_output(&self, _handle: &PtyHandle) -> Result { + Ok(Box::new(std::iter::empty())) + } + fn scrollback(&self, _handle: &PtyHandle) -> Result, PtyError> { + Ok(Vec::new()) + } + async fn kill(&self, handle: &PtyHandle) -> Result { + self.kills.lock().unwrap().push(handle.session_id); + Ok(ExitStatus { code: Some(0) }) + } +} + +/// A fake structured [`AgentSession`] recording whether `shutdown` was called. +struct FakeSession { + id: SessionId, + shutdown_called: Arc, +} + +#[async_trait] +impl AgentSession for FakeSession { + fn id(&self) -> SessionId { + self.id + } + fn conversation_id(&self) -> Option { + None + } + async fn send(&self, _prompt: &str) -> Result { + Ok(Box::new(std::iter::empty())) + } + async fn shutdown(&self) -> Result<(), AgentSessionError> { + self.shutdown_called.store(true, Ordering::SeqCst); + Ok(()) + } +} + +// --------------------------------------------------------------------------- +// wiring helpers +// --------------------------------------------------------------------------- + +struct Fixture { + attach: AttachLiveAgent, + stop: StopLiveAgent, + pty: Arc, + structured: Arc, + pty_port: Arc, +} + +fn fixture() -> Fixture { + let pty = Arc::new(TerminalSessions::new()); + let structured = Arc::new(StructuredSessions::new()); + let live = Arc::new(LiveSessions::new(Arc::clone(&pty), Arc::clone(&structured))); + let pty_port = Arc::new(FakePty::default()); + let close = Arc::new(CloseTerminal::new( + Arc::clone(&pty_port) as Arc, + Arc::clone(&pty), + )); + Fixture { + attach: AttachLiveAgent::new(Arc::clone(&live)), + stop: StopLiveAgent::new(Arc::clone(&live), close), + pty, + structured, + pty_port, + } +} + +fn insert_pty( + sessions: &TerminalSessions, + session_id: SessionId, + agent_id: AgentId, + node_id: NodeId, +) { + sessions.insert( + PtyHandle { session_id }, + TerminalSession::starting( + session_id, + node_id, + ProjectPath::new("/tmp/idea-workstate-actions").unwrap(), + SessionKind::Agent { agent_id }, + PtySize { rows: 24, cols: 80 }, + ), + ); +} + +fn insert_structured( + sessions: &StructuredSessions, + session_id: SessionId, + agent_id: AgentId, + node_id: NodeId, +) -> Arc { + let flag = Arc::new(AtomicBool::new(false)); + sessions.insert( + Arc::new(FakeSession { + id: session_id, + shutdown_called: Arc::clone(&flag), + }), + agent_id, + node_id, + ); + flag +} + +// --------------------------------------------------------------------------- +// AttachLiveAgent +// --------------------------------------------------------------------------- + +#[test] +fn attach_pty_rebinds_node_without_changing_session() { + let f = fixture(); + let a = aid(10); + insert_pty(&f.pty, sid(1), a, nid(100)); + + let out = f + .attach + .execute(AttachLiveAgentInput { + project: project(), + agent_id: a, + node_id: nid(200), + }) + .unwrap(); + + assert_eq!(out.agent_id, a); + assert_eq!(out.session_id, sid(1), "session id is preserved on rebind"); + assert_eq!(out.node_id, nid(200), "view rebound to the new node"); + assert_eq!(out.kind, LiveSessionKind::Pty); + // The registry reflects the new host node, same session. + assert_eq!(f.pty.node_for_agent(&a), Some(nid(200))); + assert_eq!(f.pty.session_for_agent(&a), Some(sid(1))); +} + +#[test] +fn attach_structured_rebinds_node() { + let f = fixture(); + let a = aid(10); + let _flag = insert_structured(&f.structured, sid(2), a, nid(100)); + + let out = f + .attach + .execute(AttachLiveAgentInput { + project: project(), + agent_id: a, + node_id: nid(300), + }) + .unwrap(); + + assert_eq!(out.session_id, sid(2)); + assert_eq!(out.node_id, nid(300)); + assert_eq!(out.kind, LiveSessionKind::Structured); + assert_eq!(f.structured.node_for_agent(&a), Some(nid(300))); +} + +#[test] +fn attach_unknown_agent_is_not_found() { + let f = fixture(); + + let err = f + .attach + .execute(AttachLiveAgentInput { + project: project(), + agent_id: aid(999), + node_id: nid(1), + }) + .expect_err("absent agent ⇒ NOT_FOUND"); + + assert!( + matches!(err, application::AppError::NotFound(_)), + "got {err:?}" + ); +} + +#[test] +fn attach_same_node_is_idempotent() { + let f = fixture(); + let a = aid(10); + insert_pty(&f.pty, sid(1), a, nid(100)); + + let first = f + .attach + .execute(AttachLiveAgentInput { + project: project(), + agent_id: a, + node_id: nid(100), + }) + .unwrap(); + let second = f + .attach + .execute(AttachLiveAgentInput { + project: project(), + agent_id: a, + node_id: nid(100), + }) + .unwrap(); + + // Re-attaching to the same node is a no-op: same session, same node, no spawn. + assert_eq!(first.session_id, second.session_id); + assert_eq!(first.node_id, nid(100)); + assert_eq!(second.node_id, nid(100)); + assert_eq!(f.pty.len(), 1, "no extra session created"); +} + +// --------------------------------------------------------------------------- +// StopLiveAgent +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn stop_pty_kills_and_removes_session() { + let f = fixture(); + let a = aid(10); + insert_pty(&f.pty, sid(1), a, nid(100)); + + let out = f + .stop + .execute(StopLiveAgentInput { + project: project(), + agent_id: a, + }) + .await + .unwrap(); + + assert_eq!(out.agent_id, a); + assert_eq!(out.session_id, sid(1)); + assert_eq!(out.kind, LiveSessionKind::Pty); + // Delegated to the close primitive: process killed and registry emptied. + assert_eq!(f.pty_port.kills(), vec![sid(1)]); + assert!(f.pty.is_empty(), "live session removed from the registry"); + assert_eq!(f.pty.session_for_agent(&a), None); +} + +#[tokio::test] +async fn stop_structured_shuts_down_and_removes_session() { + let f = fixture(); + let a = aid(10); + let flag = insert_structured(&f.structured, sid(2), a, nid(100)); + + let out = f + .stop + .execute(StopLiveAgentInput { + project: project(), + agent_id: a, + }) + .await + .unwrap(); + + assert_eq!(out.session_id, sid(2)); + assert_eq!(out.kind, LiveSessionKind::Structured); + assert!(flag.load(Ordering::SeqCst), "session.shutdown() was called"); + assert!(f.structured.is_empty(), "live session removed"); + assert_eq!(f.structured.session_id_for_agent(&a), None); + // No PTY was touched. + assert!(f.pty_port.kills().is_empty()); +} + +#[tokio::test] +async fn stop_unknown_agent_is_not_found() { + let f = fixture(); + + let err = f + .stop + .execute(StopLiveAgentInput { + project: project(), + agent_id: aid(999), + }) + .await + .expect_err("absent agent ⇒ NOT_FOUND"); + + assert!( + matches!(err, application::AppError::NotFound(_)), + "got {err:?}" + ); + assert!(f.pty_port.kills().is_empty(), "nothing killed"); +}