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

@ -72,6 +72,61 @@ pub struct ProjectWorkState {
pub conversations: Vec<ConversationWorkSummary>,
}
/// Input for [`GetAppExitWorkGuardState::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GetAppExitWorkGuardStateInput {
/// Projects currently open in the application.
pub projects: Vec<Project>,
}
/// Application-wide shutdown guard summary for the close-confirmation UX.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AppExitWorkGuardState {
/// 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,
/// Best-effort detail for compact UX display.
pub details: Vec<AppExitWorkGuardDetail>,
}
/// One work item contributing to [`AppExitWorkGuardState`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AppExitWorkGuardDetail {
/// A manifest agent is currently processing a turn.
BusyAgent {
/// Owning project id.
project_id: domain::ProjectId,
/// Owning project display name.
project_name: String,
/// Agent id.
agent_id: AgentId,
/// Agent display name.
agent_name: String,
/// Busy ticket, if carried by the mediator state.
ticket_id: Option<TicketId>,
},
/// A first-class background task is queued, running or waiting.
ActiveBackgroundTask {
/// Owning project id.
project_id: domain::ProjectId,
/// Owning project display name.
project_name: String,
/// Owning agent id.
agent_id: AgentId,
/// Owning agent display name.
agent_name: String,
/// Stable task id.
task_id: TaskId,
/// Lifecycle state.
state: BackgroundTaskState,
/// Kind discriminant.
kind: BackgroundTaskKindLabel,
},
}
/// Best-effort, read-only summary of one conversation visible through the tickets.
///
/// Derived live from the [`HandoffStore`] (primary source) with a bounded
@ -276,6 +331,80 @@ pub struct GetProjectWorkState {
background_tasks: Option<Arc<dyn BackgroundTaskStore>>,
}
/// Read-only use case aggregating app-wide work that should guard application exit.
pub struct GetAppExitWorkGuardState {
work_state: Arc<GetProjectWorkState>,
}
impl GetAppExitWorkGuardState {
/// Builds the app-exit guard from the existing per-project work-state read model.
#[must_use]
pub fn new(work_state: Arc<GetProjectWorkState>) -> Self {
Self { work_state }
}
/// Executes the guard aggregation across all currently open projects.
///
/// # Errors
/// Propagates the per-project work-state read errors.
pub async fn execute(
&self,
input: GetAppExitWorkGuardStateInput,
) -> Result<AppExitWorkGuardState, AppError> {
let mut busy_agent_count = 0;
let mut active_background_task_count = 0;
let mut details = Vec::new();
for project in input.projects {
let project_id = project.id;
let project_name = project.name.clone();
let state = self
.work_state
.execute(GetProjectWorkStateInput {
project: project.clone(),
})
.await?;
for agent in state.agents {
if agent.busy.is_busy() {
busy_agent_count += 1;
details.push(AppExitWorkGuardDetail::BusyAgent {
project_id,
project_name: project_name.clone(),
agent_id: agent.agent_id,
agent_name: agent.name.clone(),
ticket_id: agent.busy.ticket(),
});
}
for task in agent
.background_tasks
.into_iter()
.filter(|task| !task.state.is_terminal())
{
active_background_task_count += 1;
details.push(AppExitWorkGuardDetail::ActiveBackgroundTask {
project_id,
project_name: project_name.clone(),
agent_id: agent.agent_id,
agent_name: agent.name.clone(),
task_id: task.task_id,
state: task.state,
kind: task.kind,
});
}
}
}
Ok(AppExitWorkGuardState {
has_work_in_progress: busy_agent_count > 0 || active_background_task_count > 0,
busy_agent_count,
active_background_task_count,
details,
})
}
}
impl GetProjectWorkState {
/// Builds the read-model use case from existing stores/registries.
///