//! [`ListResumableAgents`] — inventaire, en lecture seule, des cellules d'agent //! reprenables à la réouverture d'un projet (ARCHITECTURE §15.2, chantier B, //! lot B1). //! //! À la réouverture d'un projet, [`crate::OpenProject`] a déjà rechargé le //! manifeste et les layouts persistés : chaque [`domain::LeafCell`] retrouve //! donc son `conversation_id` et son `agent_was_running` gelés à la fermeture. //! Ce use case **calcule l'inventaire** des cellules d'agent reprenables pour //! que la couche supérieure (commande Tauri + `ResumeProjectPanel`, lot B2) //! puisse proposer un panneau de reprise groupé. //! //! Contraintes (§15.2) : //! - **Lecture seule** : aucun PTY, aucun spawn, aucune persistance. On //! compose `resolve_doc` (lecture des layouts), le manifeste (nom + //! `profile_id`) et la liste des profils (`resume_supported`). //! - **Best-effort, jamais d'erreur** : projet / mémoire / agent absent ⇒ //! **liste vide**, jamais de panique ni d'`AppError`. C'est un inventaire //! indicatif à l'ouverture, pas une opération critique. //! - **Filtre** : on ne retient qu'une leaf d'agent dont `agent_was_running` //! est vrai **ou** qui porte un `conversation_id`. Une cellule d'agent jamais //! lancée (ni id, ni flag) n'apparaît pas : elle se lancera normalement au //! clic, sans popup. use std::sync::Arc; use domain::ports::{AgentContextStore, FileSystem, ProfileStore, ProjectStore}; use domain::{AgentId, NodeId, Project}; use crate::error::AppError; use crate::layout::resolve_doc; /// Input de [`ListResumableAgents::execute`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ListResumableAgentsInput { /// Le projet dont on inventorie les cellules reprenables. pub project: Project, } /// Une cellule d'agent reprenable, telle qu'exposée à la couche supérieure. /// /// Champs alignés sur la spec §15.2 : l'identité de l'agent et de sa cellule /// hôte, l'id de conversation à reprendre (`None` ⇒ relance à neuf), l'état /// « tournait » gelé à la fermeture, et si son profil sait reprendre une /// conversation CLI. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ResumableAgent { /// Identifiant de l'agent. pub agent_id: AgentId, /// Nom d'affichage de l'agent (résolu via le manifeste). pub name: String, /// Cellule hôte où relancer/reprendre l'agent. pub node_id: NodeId, /// Id de conversation CLI persistant porté par la cellule. `None` ⇒ relance /// à neuf (pas d'historique à reprendre). pub conversation_id: Option, /// Valeur de `agent_was_running` gelée à la fermeture de la cellule. pub was_running: bool, /// `true` si le profil de l'agent possède une [`domain::SessionStrategy`] /// exploitable (présence d'un `resume_flag`). pub resume_supported: bool, } /// Output de [`ListResumableAgents::execute`]. #[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct ListResumableAgentsOutput { /// Les cellules d'agent reprenables, dans l'ordre de parcours des layouts. pub resumable: Vec, } /// Inventorie, en lecture seule, les cellules d'agent reprenables d'un projet. /// /// Compose trois ports déjà injectés au composition root, **sans** en ajouter /// de nouveau : /// - [`ProjectStore`] + [`FileSystem`] : charger les layouts persistés /// (`resolve_doc`), /// - [`AgentContextStore`] : le manifeste, pour le nom et le `profile_id` de /// chaque agent, /// - [`ProfileStore`] : pour déterminer `resume_supported`. pub struct ListResumableAgents { #[allow(dead_code)] store: Arc, fs: Arc, contexts: Arc, profiles: Arc, } impl ListResumableAgents { /// Construit le use case à partir de ses ports injectés. #[must_use] pub fn new( store: Arc, fs: Arc, contexts: Arc, profiles: Arc, ) -> Self { Self { store, fs, contexts, profiles, } } /// Calcule l'inventaire des cellules reprenables. /// /// Parcourt chaque layout du projet et, pour chaque leaf portant un agent, /// lit `conversation_id`/`agent_was_running` (via l'accessor pur /// [`domain::LayoutTree::leaf`]). Ne retient que les leaves passant le /// filtre `was_running || conversation_id.is_some()`, résout le nom via le /// manifeste et `resume_supported` via le profil. /// /// **Best-effort** : toute défaillance de lecture (projet/mémoire absent, /// layouts illisibles, manifeste/profils en erreur) dégrade vers une /// **liste vide** ; cette fonction ne renvoie donc jamais d'erreur, mais sa /// signature reste `Result` pour rester homogène avec les autres use cases. /// /// # Errors /// N'échoue jamais en pratique (best-effort) ; la signature `Result` est /// conservée par cohérence. pub async fn execute( &self, input: ListResumableAgentsInput, ) -> Result { // Layouts persistés. Toute erreur ⇒ inventaire vide (best-effort). let Ok(doc) = resolve_doc(self.fs.as_ref(), &input.project).await else { return Ok(ListResumableAgentsOutput::default()); }; // Manifeste (nom + profile_id). Absent/illisible ⇒ inventaire vide : // sans manifeste on ne peut résoudre ni nom ni profil. let Ok(manifest) = self.contexts.load_manifest(&input.project).await else { return Ok(ListResumableAgentsOutput::default()); }; // Profils disponibles, pour `resume_supported`. Indisponibles ⇒ on // considère qu'aucun profil ne sait reprendre (best-effort), sans // pour autant masquer les agents reprenables par `conversation_id`. let profiles = self.profiles.list().await.unwrap_or_default(); let mut resumable = Vec::new(); for named in &doc.layouts { for (node_id, agent_id) in named.tree.agent_leaves() { // Lecture pure de la cellule hôte pour ses champs de reprise. let Some(leaf) = named.tree.leaf(node_id) else { continue; }; let conversation_id = leaf.conversation_id.clone(); let was_running = leaf.agent_was_running; // Filtre §15.2 : reprenable au sens strict uniquement. if !was_running && conversation_id.is_none() { continue; } // Nom via le manifeste ; agent absent ⇒ on ignore la leaf // (best-effort : pas d'entrée orpheline dans l'inventaire). let Some(entry) = manifest.entries.iter().find(|e| e.agent_id == agent_id) else { continue; }; let name = entry.name.clone(); // `resume_supported` : le profil de l'agent sait-il reprendre une // conversation ? Vrai si : // - il porte une `SessionStrategy` (TUI/PTY avec `resume_flag`, // sémantique §15), **ou** // - il est **structuré** (`structured_adapter`, §17) : l'adapter // Claude (`--resume`) / Codex (`exec resume`) passe le flag de // reprise du moteur via `SessionPlan::Resume`, donc la reprise est // intrinsèquement supportée. let resume_supported = entry .to_agent() .ok() .and_then(|agent| profiles.iter().find(|p| p.id == agent.profile_id)) .is_some_and(|profile| { profile.session.is_some() || profile.structured_adapter.is_some() }); resumable.push(ResumableAgent { agent_id, name, node_id, conversation_id, was_running, resume_supported, }); } } Ok(ListResumableAgentsOutput { resumable }) } }