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:
@ -9,19 +9,20 @@ use tauri::State;
|
|||||||
|
|
||||||
use crate::dto::DismissEmbedderSuggestionRequestDto;
|
use crate::dto::DismissEmbedderSuggestionRequestDto;
|
||||||
use application::{
|
use application::{
|
||||||
AppError, AssignSkillToAgentInput, ChangeAgentProfileInput, CloseProjectInput,
|
AppError, AssignSkillToAgentInput, AttachLiveAgentInput, ChangeAgentProfileInput,
|
||||||
CreateAgentInput, CreateLayoutInput, CreateMemoryInput, CreateSkillInput, DeleteAgentInput,
|
CloseProjectInput, CreateAgentInput, CreateLayoutInput, CreateMemoryInput, CreateSkillInput,
|
||||||
DeleteEmbedderProfileInput, DeleteLayoutInput, DeleteMemoryInput, DeleteSkillInput,
|
DeleteAgentInput, DeleteEmbedderProfileInput, DeleteLayoutInput, DeleteMemoryInput,
|
||||||
DeleteTemplateInput, DetectAgentDriftInput, GetMemoryInput, GetProjectWorkStateInput,
|
DeleteSkillInput, DeleteTemplateInput, DetectAgentDriftInput, GetMemoryInput,
|
||||||
GitBranchesInput, GitCheckoutInput, GitCommitInput, GitGraphInput, GitInitInput, GitLogInput,
|
GetProjectWorkStateInput, GitBranchesInput, GitCheckoutInput, GitCommitInput, GitGraphInput,
|
||||||
GitStagePathInput, GitStatusInput, InspectConversationInput, LaunchAgentInput, ListAgentsInput,
|
GitInitInput, GitLogInput, GitStagePathInput, GitStatusInput, InspectConversationInput,
|
||||||
ListLayoutsInput, ListMemoriesInput, ListResumableAgentsInput, ListSkillsInput, LiveSessions,
|
LaunchAgentInput, ListAgentsInput, ListLayoutsInput, ListMemoriesInput,
|
||||||
LoadLayoutInput, McpRuntime, MutateLayoutInput, OpenProjectInput, ReadAgentContextInput,
|
ListResumableAgentsInput, ListSkillsInput, LiveSessions, LoadLayoutInput, McpRuntime,
|
||||||
ReadMemoryIndexInput, ReadProjectContextInput, RecallMemoryInput, ReconcileLayoutsInput,
|
MutateLayoutInput, OpenProjectInput, ReadAgentContextInput, ReadMemoryIndexInput,
|
||||||
RenameLayoutInput, ResolveAgentPermissionsInput, ResolveMemoryLinksInput, SetActiveLayoutInput,
|
ReadProjectContextInput, RecallMemoryInput, ReconcileLayoutsInput, RenameLayoutInput,
|
||||||
SnapshotRunningAgentsInput, SyncAgentWithTemplateInput, UnassignSkillFromAgentInput,
|
ResolveAgentPermissionsInput, ResolveMemoryLinksInput, SetActiveLayoutInput,
|
||||||
UpdateAgentContextInput, UpdateAgentPermissionsInput, UpdateMemoryInput,
|
SnapshotRunningAgentsInput, StopLiveAgentInput, SyncAgentWithTemplateInput,
|
||||||
UpdateProjectContextInput, UpdateProjectPermissionsInput, UpdateSkillInput,
|
UnassignSkillFromAgentInput, UpdateAgentContextInput, UpdateAgentPermissionsInput,
|
||||||
|
UpdateMemoryInput, UpdateProjectContextInput, UpdateProjectPermissionsInput, UpdateSkillInput,
|
||||||
};
|
};
|
||||||
use domain::ports::PtyHandle;
|
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_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_node_id, parse_profile_id, parse_project_id, parse_session_id, parse_skill_id,
|
||||||
parse_template_id, parse_ticket_id, AgentDriftListDto, AgentDto, AgentListDto,
|
parse_template_id, parse_ticket_id, AgentDriftListDto, AgentDto, AgentListDto,
|
||||||
AssignSkillRequestDto, AttachLiveAgentRequestDto, ChangeAgentProfileDto,
|
AssignSkillRequestDto, AttachLiveAgentRequestDto, AttachLiveAgentResponseDto,
|
||||||
ChangeAgentProfileRequestDto, ConfigureProfilesRequestDto, ConversationDetailsDto,
|
ChangeAgentProfileDto, ChangeAgentProfileRequestDto, ConfigureProfilesRequestDto,
|
||||||
CreateAgentFromTemplateRequestDto, CreateAgentRequestDto, CreateLayoutRequestDto,
|
ConversationDetailsDto, CreateAgentFromTemplateRequestDto, CreateAgentRequestDto,
|
||||||
CreateLayoutResultDto, CreateMemoryRequestDto, CreateProjectRequestDto, CreateSkillRequestDto,
|
CreateLayoutRequestDto, CreateLayoutResultDto, CreateMemoryRequestDto, CreateProjectRequestDto,
|
||||||
CreateTemplateRequestDto, DeleteLayoutRequestDto, DeleteLayoutResultDto,
|
CreateSkillRequestDto, CreateTemplateRequestDto, DeleteLayoutRequestDto, DeleteLayoutResultDto,
|
||||||
DeliveredDelegationRequestDto, DetectProfilesRequestDto, DetectProfilesResponseDto,
|
DeliveredDelegationRequestDto, DetectProfilesRequestDto, DetectProfilesResponseDto,
|
||||||
EffectivePermissionsDto, EmbedderEnginesDto, EmbedderProfileDto, EmbedderProfileListDto,
|
EffectivePermissionsDto, EmbedderEnginesDto, EmbedderProfileDto, EmbedderProfileListDto,
|
||||||
ErrorDto, FirstRunStateDto, FrontAttachedRequestDto, GitBranchesDto, GitCheckoutRequestDto,
|
ErrorDto, FirstRunStateDto, FrontAttachedRequestDto, GitBranchesDto, GitCheckoutRequestDto,
|
||||||
@ -46,7 +47,8 @@ use crate::dto::{
|
|||||||
ReattachResultDto, RecallMemoryRequestDto, RenameLayoutRequestDto, ReplyChunk,
|
ReattachResultDto, RecallMemoryRequestDto, RenameLayoutRequestDto, ReplyChunk,
|
||||||
ResizeTerminalRequestDto, ResolveAgentPermissionsRequestDto, ResumableAgentListDto,
|
ResizeTerminalRequestDto, ResolveAgentPermissionsRequestDto, ResumableAgentListDto,
|
||||||
SaveEmbedderProfileRequestDto, SaveProfileRequestDto, SetActiveLayoutRequestDto, SkillDto,
|
SaveEmbedderProfileRequestDto, SaveProfileRequestDto, SetActiveLayoutRequestDto, SkillDto,
|
||||||
SkillListDto, SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto, TemplateListDto,
|
SkillListDto, StopLiveAgentRequestDto, StopLiveAgentResponseDto,
|
||||||
|
SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto, TemplateListDto,
|
||||||
TerminalClosedDto, TerminalSessionDto, UnassignSkillRequestDto, UpdateAgentContextRequestDto,
|
TerminalClosedDto, TerminalSessionDto, UnassignSkillRequestDto, UpdateAgentContextRequestDto,
|
||||||
UpdateAgentPermissionsRequestDto, UpdateMemoryRequestDto, UpdateProjectContextRequestDto,
|
UpdateAgentPermissionsRequestDto, UpdateMemoryRequestDto, UpdateProjectContextRequestDto,
|
||||||
UpdateProjectPermissionsRequestDto, UpdateSkillRequestDto, UpdateTemplateRequestDto,
|
UpdateProjectPermissionsRequestDto, UpdateSkillRequestDto, UpdateTemplateRequestDto,
|
||||||
@ -1036,20 +1038,42 @@ pub fn list_live_agents(
|
|||||||
pub async fn attach_live_agent(
|
pub async fn attach_live_agent(
|
||||||
request: AttachLiveAgentRequestDto,
|
request: AttachLiveAgentRequestDto,
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
) -> Result<LiveAgentListDto, ErrorDto> {
|
) -> Result<AttachLiveAgentResponseDto, ErrorDto> {
|
||||||
let _project = resolve_project(&request.project_id, &state).await?;
|
let project = resolve_project(&request.project_id, &state).await?;
|
||||||
let agent_id = parse_agent_id(&request.agent_id)?;
|
let agent_id = parse_agent_id(&request.agent_id)?;
|
||||||
let node_id = parse_node_id(&request.node_id)?;
|
let node_id = parse_node_id(&request.node_id)?;
|
||||||
let session = state
|
state
|
||||||
.terminal_sessions
|
.attach_live_agent
|
||||||
.rebind_agent_node(&agent_id, node_id)
|
.execute(AttachLiveAgentInput {
|
||||||
.ok_or_else(|| AppError::NotFound(format!("running session for agent {agent_id}")))?;
|
project,
|
||||||
|
|
||||||
Ok(LiveAgentListDto::from_pairs(vec![(
|
|
||||||
agent_id,
|
agent_id,
|
||||||
session.node_id,
|
node_id,
|
||||||
session.id,
|
})
|
||||||
)]))
|
.map(AttachLiveAgentResponseDto::from)
|
||||||
|
.map_err(ErrorDto::from)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `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<StopLiveAgentResponseDto, ErrorDto> {
|
||||||
|
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.
|
/// `read_agent_context` — read an agent's Markdown context.
|
||||||
|
|||||||
@ -9,10 +9,10 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use application::{
|
use application::{
|
||||||
AgentTicketState, AppError, ConversationPreviewStatus, ConversationTurnWorkPreview,
|
AgentTicketState, AppError, AttachLiveAgentOutput, ConversationPreviewStatus,
|
||||||
ConversationWorkSummary, CreateProjectInput, CreateProjectOutput, GitGraphOutput, HealthInput,
|
ConversationTurnWorkPreview, ConversationWorkSummary, CreateProjectInput, CreateProjectOutput,
|
||||||
HealthReport, LayoutKind, ListProjectsOutput, LiveSessionKind, OpenProjectOutput,
|
GitGraphOutput, HealthInput, HealthReport, LayoutKind, ListProjectsOutput, LiveSessionKind,
|
||||||
ProjectWorkState, TicketWorkSource, TicketWorkStatus,
|
OpenProjectOutput, ProjectWorkState, StopLiveAgentOutput, TicketWorkSource, TicketWorkStatus,
|
||||||
};
|
};
|
||||||
use domain::{AgentBusyState, Project, ProjectId, TurnRole};
|
use domain::{AgentBusyState, Project, ProjectId, TurnRole};
|
||||||
|
|
||||||
@ -1751,6 +1751,64 @@ pub struct AttachLiveAgentRequestDto {
|
|||||||
pub node_id: String,
|
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<AttachLiveAgentOutput> 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<StopLiveAgentOutput> 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.
|
/// Parses an agent-id string (UUID) coming from the frontend.
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
|
|||||||
@ -166,6 +166,7 @@ pub fn run() {
|
|||||||
commands::get_project_work_state,
|
commands::get_project_work_state,
|
||||||
commands::list_live_agents,
|
commands::list_live_agents,
|
||||||
commands::attach_live_agent,
|
commands::attach_live_agent,
|
||||||
|
commands::stop_live_agent,
|
||||||
commands::read_agent_context,
|
commands::read_agent_context,
|
||||||
commands::update_agent_context,
|
commands::update_agent_context,
|
||||||
commands::delete_agent,
|
commands::delete_agent,
|
||||||
|
|||||||
@ -12,22 +12,23 @@ use std::path::PathBuf;
|
|||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
use application::{
|
use application::{
|
||||||
AgentResumer, AppError, AssignSkillToAgent, ChangeAgentProfile, CheckEmbedderSuggestion,
|
AgentResumer, AppError, AssignSkillToAgent, AttachLiveAgent, ChangeAgentProfile,
|
||||||
CloseProject, CloseTab, CloseTerminal, ConfigureProfiles, ContextGuardUseCases,
|
CheckEmbedderSuggestion, CloseProject, CloseTab, CloseTerminal, ConfigureProfiles,
|
||||||
CreateAgentFromScratch, CreateAgentFromTemplate, CreateLayout, CreateMemory, CreateProject,
|
ContextGuardUseCases, CreateAgentFromScratch, CreateAgentFromTemplate, CreateLayout,
|
||||||
CreateSkill, CreateTemplate, DeleteAgent, DeleteEmbedderProfile, DeleteLayout, DeleteMemory,
|
CreateMemory, CreateProject, CreateSkill, CreateTemplate, DeleteAgent, DeleteEmbedderProfile,
|
||||||
DeleteProfile, DeleteSkill, DeleteTemplate, DescribeEmbedderEngines, DetectAgentDrift,
|
DeleteLayout, DeleteMemory, DeleteProfile, DeleteSkill, DeleteTemplate,
|
||||||
DetectProfiles, DismissEmbedderSuggestion, FirstRunState, GetMemory, GetProjectPermissions,
|
DescribeEmbedderEngines, DetectAgentDrift, DetectProfiles, DismissEmbedderSuggestion,
|
||||||
GetProjectWorkState, GitBranches, GitCheckout, GitCommit, GitGraph, GitInit, GitLog, GitStage,
|
FirstRunState, GetMemory, GetProjectPermissions, GetProjectWorkState, GitBranches, GitCheckout,
|
||||||
GitStatus, GitUnstage, HealthUseCase, InspectConversation, LaunchAgent, LaunchAgentInput,
|
GitCommit, GitGraph, GitInit, GitLog, GitStage, GitStatus, GitUnstage, HealthUseCase,
|
||||||
ListAgents, ListAgentsInput, ListEmbedderProfiles, ListLayouts, ListMemories, ListProfiles,
|
InspectConversation, LaunchAgent, LaunchAgentInput, ListAgents, ListAgentsInput,
|
||||||
ListProjects, ListResumableAgents, ListSkills, ListTemplates, LiveAgentRegistry, LiveSessions,
|
ListEmbedderProfiles, ListLayouts, ListMemories, ListProfiles, ListProjects,
|
||||||
LoadLayout, McpRuntime, MoveTabToNewWindow, MutateLayout, OnnxModelView, OpenProject,
|
ListResumableAgents, ListSkills, ListTemplates, LiveAgentRegistry, LiveSessions, LoadLayout,
|
||||||
OpenTerminal, OrchestratorService, PermissionProjectorRegistry, ProposeContext,
|
McpRuntime, MoveTabToNewWindow, MutateLayout, OnnxModelView, OpenProject, OpenTerminal,
|
||||||
ReadAgentContext, ReadContext, ReadMemory, ReadMemoryIndex, ReadProjectContext, RecallMemory,
|
OrchestratorService, PermissionProjectorRegistry, ProposeContext, ReadAgentContext,
|
||||||
ReconcileLayouts, RecordTurn, RecordTurnProvider, ReferenceProfiles, RenameLayout,
|
ReadContext, ReadMemory, ReadMemoryIndex, ReadProjectContext, RecallMemory, ReconcileLayouts,
|
||||||
ResizeTerminal, ResolveAgentPermissions, ResolveMemoryLinks, SaveEmbedderProfile, SaveProfile,
|
RecordTurn, RecordTurnProvider, ReferenceProfiles, RenameLayout, ResizeTerminal,
|
||||||
SessionLimitService, SetActiveLayout, SnapshotRunningAgents, StructuredSessions,
|
ResolveAgentPermissions, ResolveMemoryLinks, SaveEmbedderProfile, SaveProfile,
|
||||||
|
SessionLimitService, SetActiveLayout, SnapshotRunningAgents, StopLiveAgent, StructuredSessions,
|
||||||
SuggestedThisSession, SyncAgentWithTemplate, TerminalSessions, UnassignSkillFromAgent,
|
SuggestedThisSession, SyncAgentWithTemplate, TerminalSessions, UnassignSkillFromAgent,
|
||||||
UpdateAgentContext, UpdateAgentPermissions, UpdateMemory, UpdateProjectContext,
|
UpdateAgentContext, UpdateAgentPermissions, UpdateMemory, UpdateProjectContext,
|
||||||
UpdateProjectPermissions, UpdateSkill, UpdateTemplate, WriteMemory, WriteToTerminal,
|
UpdateProjectPermissions, UpdateSkill, UpdateTemplate, WriteMemory, WriteToTerminal,
|
||||||
@ -351,6 +352,10 @@ pub struct AppState {
|
|||||||
pub list_resumable_agents: Arc<ListResumableAgents>,
|
pub list_resumable_agents: Arc<ListResumableAgents>,
|
||||||
/// Read-only live/busy state for the project's manifest agents.
|
/// Read-only live/busy state for the project's manifest agents.
|
||||||
pub get_project_work_state: Arc<GetProjectWorkState>,
|
pub get_project_work_state: Arc<GetProjectWorkState>,
|
||||||
|
/// Rebinds an already-running agent's live session to a visible cell (Lot D).
|
||||||
|
pub attach_live_agent: Arc<AttachLiveAgent>,
|
||||||
|
/// Tears down an already-running agent's live session by agent id (Lot D).
|
||||||
|
pub stop_live_agent: Arc<StopLiveAgent>,
|
||||||
/// Best-effort inspection of a conversation (last topic + token indicator)
|
/// Best-effort inspection of a conversation (last topic + token indicator)
|
||||||
/// for the resume popup (T7). Optional/extensible: backed by a `Vec` of
|
/// for the resume popup (T7). Optional/extensible: backed by a `Vec` of
|
||||||
/// [`domain::ports::SessionInspector`]s; an empty/missing match yields empty
|
/// [`domain::ports::SessionInspector`]s; an empty/missing match yields empty
|
||||||
@ -1066,6 +1071,14 @@ impl AppState {
|
|||||||
as Arc<dyn application::ConversationLogProvider>,
|
as Arc<dyn application::ConversationLogProvider>,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
// 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) ---
|
// --- Limites de session des agents (ARCHITECTURE §21, LS7) ---
|
||||||
// Service pur-ports « détecter → planifier → reprendre » câblé sur l'existant :
|
// Service pur-ports « détecter → planifier → reprendre » câblé sur l'existant :
|
||||||
@ -1233,6 +1246,8 @@ impl AppState {
|
|||||||
change_agent_profile,
|
change_agent_profile,
|
||||||
list_resumable_agents,
|
list_resumable_agents,
|
||||||
get_project_work_state,
|
get_project_work_state,
|
||||||
|
attach_live_agent,
|
||||||
|
stop_live_agent,
|
||||||
inspect_conversation,
|
inspect_conversation,
|
||||||
project_store,
|
project_store,
|
||||||
get_project_permissions,
|
get_project_permissions,
|
||||||
|
|||||||
@ -552,3 +552,82 @@ fn launch_agent_output_propagates_assigned_conversation_id() {
|
|||||||
"no snake_case leak"
|
"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());
|
||||||
|
}
|
||||||
|
|||||||
@ -119,8 +119,9 @@ pub use terminal::{
|
|||||||
};
|
};
|
||||||
pub use window::{MoveTabToNewWindow, MoveTabToNewWindowInput, MoveTabToNewWindowOutput};
|
pub use window::{MoveTabToNewWindow, MoveTabToNewWindowInput, MoveTabToNewWindowOutput};
|
||||||
pub use workstate::{
|
pub use workstate::{
|
||||||
AgentTicketState, AgentWorkState, ConversationLogProvider, ConversationPreviewStatus,
|
AgentTicketState, AgentWorkState, AttachLiveAgent, AttachLiveAgentInput, AttachLiveAgentOutput,
|
||||||
ConversationTurnWorkPreview, ConversationWorkSummary, GetProjectWorkState,
|
ConversationLogProvider, ConversationPreviewStatus, ConversationTurnWorkPreview,
|
||||||
GetProjectWorkStateInput, LiveWorkSession, ProjectWorkState, TicketWorkSource,
|
ConversationWorkSummary, GetProjectWorkState, GetProjectWorkStateInput, LiveWorkSession,
|
||||||
|
ProjectWorkState, StopLiveAgent, StopLiveAgentInput, StopLiveAgentOutput, TicketWorkSource,
|
||||||
TicketWorkStatus,
|
TicketWorkStatus,
|
||||||
};
|
};
|
||||||
|
|||||||
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
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -5,6 +5,13 @@
|
|||||||
//! beyond loading the manifest through the existing context store and creates no
|
//! beyond loading the manifest through the existing context store and creates no
|
||||||
//! durable projection.
|
//! durable projection.
|
||||||
|
|
||||||
|
mod actions;
|
||||||
|
|
||||||
|
pub use actions::{
|
||||||
|
AttachLiveAgent, AttachLiveAgentInput, AttachLiveAgentOutput, StopLiveAgent,
|
||||||
|
StopLiveAgentInput, StopLiveAgentOutput,
|
||||||
|
};
|
||||||
|
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
|||||||
351
crates/application/tests/workstate_actions.rs
Normal file
351
crates/application/tests/workstate_actions.rs
Normal file
@ -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<Vec<SessionId>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FakePty {
|
||||||
|
fn kills(&self) -> Vec<SessionId> {
|
||||||
|
self.kills.lock().unwrap().clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl PtyPort for FakePty {
|
||||||
|
async fn spawn(&self, _spec: SpawnSpec, _size: PtySize) -> Result<PtyHandle, PtyError> {
|
||||||
|
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<OutputStream, PtyError> {
|
||||||
|
Ok(Box::new(std::iter::empty()))
|
||||||
|
}
|
||||||
|
fn scrollback(&self, _handle: &PtyHandle) -> Result<Vec<u8>, PtyError> {
|
||||||
|
Ok(Vec::new())
|
||||||
|
}
|
||||||
|
async fn kill(&self, handle: &PtyHandle) -> Result<ExitStatus, PtyError> {
|
||||||
|
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<AtomicBool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl AgentSession for FakeSession {
|
||||||
|
fn id(&self) -> SessionId {
|
||||||
|
self.id
|
||||||
|
}
|
||||||
|
fn conversation_id(&self) -> Option<String> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
async fn send(&self, _prompt: &str) -> Result<ReplyStream, AgentSessionError> {
|
||||||
|
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<TerminalSessions>,
|
||||||
|
structured: Arc<StructuredSessions>,
|
||||||
|
pty_port: Arc<FakePty>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<dyn PtyPort>,
|
||||||
|
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<AtomicBool> {
|
||||||
|
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");
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user