feat(backend): guard de fermeture "travail en cours" (#83)

Expose l'état du guard de sortie applicative (GetAppExitWorkGuardState) :
agents busy + tâches d'arrière-plan actives à travers tous les projets
ouverts, avec détails compacts pour la popup de confirmation. Le handler
CloseRequested d'app-tauri interroge ce guard avant de laisser la fenêtre
se fermer, et respecte la confirmation explicite de l'utilisateur
(EXIT_GUARD_CONFIRMED) pour ne pas la redemander en boucle.

QA vert (backend + frontend).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-20 19:18:39 +02:00
parent 8509653e3c
commit 60f4b33e53
7 changed files with 755 additions and 109 deletions

View File

@ -9,12 +9,12 @@
use serde::{Deserialize, Serialize};
use application::{
AgentBackgroundTaskState, AgentTicketState, AppError, AttachLiveAgentOutput,
BackgroundTaskKindLabel, ConversationPreviewStatus, ConversationTurnWorkPreview,
ConversationWorkSummary, CreateProjectInput, CreateProjectOutput, GitGraphOutput, HealthInput,
HealthReport, LayoutKind, ListProjectsOutput, LiveSessionKind, LiveSessionSnapshot,
OpenProjectOutput, ProjectWorkState, StopLiveAgentOutput, TicketWorkSource, TicketWorkStatus,
TurnPage, TurnSource, TurnView,
AgentBackgroundTaskState, AgentTicketState, AppError, AppExitWorkGuardDetail,
AppExitWorkGuardState, AttachLiveAgentOutput, BackgroundTaskKindLabel,
ConversationPreviewStatus, ConversationTurnWorkPreview, ConversationWorkSummary,
CreateProjectInput, CreateProjectOutput, GitGraphOutput, HealthInput, HealthReport, LayoutKind,
ListProjectsOutput, LiveSessionKind, LiveSessionSnapshot, OpenProjectOutput, ProjectWorkState,
StopLiveAgentOutput, TicketWorkSource, TicketWorkStatus, TurnPage, TurnSource, TurnView,
};
use domain::{AgentBusyState, PageCursor, PageDirection, Project, ProjectId, TurnRole};
@ -2275,6 +2275,111 @@ impl From<ProjectWorkState> for ProjectWorkStateDto {
}
}
/// App-wide shutdown guard read model for the exit confirmation flow.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AppExitWorkGuardStateDto {
/// Whether at least one active work item would be interrupted by app exit.
pub has_work_in_progress: bool,
/// Number of busy agents across all open projects.
pub busy_agent_count: usize,
/// Number of non-terminal background tasks across all open projects.
pub active_background_task_count: usize,
/// Total active work items.
pub total_work_count: usize,
/// Best-effort compact details for the confirmation dialog.
pub details: Vec<AppExitWorkGuardDetailDto>,
}
impl From<AppExitWorkGuardState> for AppExitWorkGuardStateDto {
fn from(state: AppExitWorkGuardState) -> Self {
Self {
has_work_in_progress: state.has_work_in_progress,
busy_agent_count: state.busy_agent_count,
active_background_task_count: state.active_background_task_count,
total_work_count: state.busy_agent_count + state.active_background_task_count,
details: state
.details
.into_iter()
.map(AppExitWorkGuardDetailDto::from)
.collect(),
}
}
}
/// One active work item contributing to [`AppExitWorkGuardStateDto`].
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase", tag = "kind")]
pub enum AppExitWorkGuardDetailDto {
/// A manifest agent is currently processing a turn.
BusyAgent {
/// Owning project id.
project_id: String,
/// Owning project display name.
project_name: String,
/// Agent id.
agent_id: String,
/// Agent display name.
agent_name: String,
/// Busy ticket id, when available.
ticket_id: Option<String>,
},
/// A first-class background task is queued, running or waiting.
ActiveBackgroundTask {
/// Owning project id.
project_id: String,
/// Owning project display name.
project_name: String,
/// Owning agent id.
agent_id: String,
/// Owning agent display name.
agent_name: String,
/// Stable task id.
task_id: String,
/// Lifecycle state.
state: String,
/// Kind discriminant.
task_kind: String,
},
}
impl From<AppExitWorkGuardDetail> for AppExitWorkGuardDetailDto {
fn from(detail: AppExitWorkGuardDetail) -> Self {
match detail {
AppExitWorkGuardDetail::BusyAgent {
project_id,
project_name,
agent_id,
agent_name,
ticket_id,
} => Self::BusyAgent {
project_id: project_id.to_string(),
project_name,
agent_id: agent_id.to_string(),
agent_name,
ticket_id: ticket_id.map(|id| id.to_string()),
},
AppExitWorkGuardDetail::ActiveBackgroundTask {
project_id,
project_name,
agent_id,
agent_name,
task_id,
state,
kind,
} => Self::ActiveBackgroundTask {
project_id: project_id.to_string(),
project_name,
agent_id: agent_id.to_string(),
agent_name,
task_id: task_id.to_string(),
state: background_state_label(state).to_owned(),
task_kind: background_kind_label_from_work_state(kind).to_owned(),
},
}
}
}
/// Request DTO for `attach_live_agent`: bind an already-running agent session to
/// a visible layout cell without spawning a new process.
#[derive(Debug, Clone, Deserialize)]