- LaunchAgent route sur profile.structured_adapter : Some ⇒ AgentSession via
factory + enregistrement StructuredSessions (aucun pty.spawn) ; None ⇒ PTY
inchangé. Dépendances structurées injectées par builders additifs
with_structured (signatures publiques inchangées ⇒ A/B legacy verts).
- LaunchAgentOutput étendu d'un champ optionnel structured (non cassant).
- A (ChangeAgentProfile) : kill polymorphe — session structurée ⇒ shutdown(),
PTY ⇒ kill ; détection sur les deux registres.
- B : resume_supported vrai pour profil structuré ; resolve_session_plan ⇒
Resume{conversation_id} (Claude --resume / Codex exec resume).
- Codex : build_spawn_line porte --sandbox workspace-write --ask-for-approval
never (autonomie d'écriture ; à terme piloté par les permissions).
Tests : structured_launch 8 + codex flags 2 ; application/infrastructure 0 échec.
Reste D4 : ChatBridge (Channel) + commandes agent_send/reattach + StructuredSessions
dans AppState + DTO cellKind/ReplyChunk.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
194 lines
8.1 KiB
Rust
194 lines
8.1 KiB
Rust
//! [`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<String>,
|
|
/// 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<ResumableAgent>,
|
|
}
|
|
|
|
/// 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<dyn ProjectStore>,
|
|
fs: Arc<dyn FileSystem>,
|
|
contexts: Arc<dyn AgentContextStore>,
|
|
profiles: Arc<dyn ProfileStore>,
|
|
}
|
|
|
|
impl ListResumableAgents {
|
|
/// Construit le use case à partir de ses ports injectés.
|
|
#[must_use]
|
|
pub fn new(
|
|
store: Arc<dyn ProjectStore>,
|
|
fs: Arc<dyn FileSystem>,
|
|
contexts: Arc<dyn AgentContextStore>,
|
|
profiles: Arc<dyn ProfileStore>,
|
|
) -> 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<ListResumableAgentsOutput, AppError> {
|
|
// 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 })
|
|
}
|
|
}
|