//! [`SnapshotRunningAgents`] — freeze, at close time, which agent cells still //! held a live PTY (feature: "conversation resume", task T5). //! //! When the IDE (or a single project) closes, every live PTY is about to be //! killed. *Before* that happens, we record on each agent-bearing leaf whether //! its agent process was still running (`agent_was_running = true`) or had //! already exited / never launched (`false`). On reopen, that flag is what tells //! the resume popup "this conversation was still in progress" vs "it was closed". //! //! The decision is **universal**: it is derived purely from the process //! lifecycle (the live-session registry), never from parsing CLI output. The use //! case itself is a thin orchestrator over: //! - the persisted layouts store ([`super::store`]), //! - the pure domain operation [`domain::LayoutTree::set_agent_running`], //! - a [`LiveAgentRegistry`] liveness query. //! //! **Ordering contract:** this snapshot reads the registry *as it is at call //! time*. The composition root (app-tauri) is responsible for calling it //! **before** the global PTY kill; if the kill ran first, every agent would look //! "closed". See `app-tauri`'s `CloseRequested` hook and `close.rs` callers. use std::sync::Arc; use domain::ports::{FileSystem, ProjectStore}; use domain::ProjectId; use crate::error::AppError; use crate::terminal::LiveAgentRegistry; use super::store::{persist_doc, resolve_doc}; /// Input for [`SnapshotRunningAgents::execute`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct SnapshotRunningAgentsInput { /// The project whose layouts must be frozen. pub project_id: ProjectId, } /// Output of [`SnapshotRunningAgents::execute`]. #[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct SnapshotRunningAgentsOutput { /// Number of agent-bearing leaves that were found running at snapshot time. pub running: usize, /// Number of agent-bearing leaves that were found stopped at snapshot time. pub stopped: usize, } /// Freezes the `agent_was_running` flag on every agent leaf of a project's /// layouts, from the live-session registry, then persists the layouts. pub struct SnapshotRunningAgents { store: Arc, fs: Arc, live: Arc, } impl SnapshotRunningAgents { /// Builds the use case from its injected ports. #[must_use] pub fn new( store: Arc, fs: Arc, live: Arc, ) -> Self { Self { store, fs, live } } /// Executes the snapshot for one project. /// /// Walks every named layout of the project; for each agent-bearing leaf it /// applies [`domain::LayoutTree::set_agent_running`] with the agent's *current* /// liveness, then persists the whole layouts store. A project with no /// agent leaf is a no-op (the doc is still resolved/healed, never written if /// unchanged). /// /// # Errors /// - [`AppError::NotFound`] if the project is unknown, /// - [`AppError::FileSystem`] on persistence failure, /// - [`AppError::Store`] on registry I/O failure. pub async fn execute( &self, input: SnapshotRunningAgentsInput, ) -> Result { let project = self.store.load_project(input.project_id).await?; let mut doc = resolve_doc(self.fs.as_ref(), &project).await?; let mut out = SnapshotRunningAgentsOutput::default(); let mut changed = false; for named in &mut doc.layouts { for (leaf_id, agent_id) in named.tree.agent_leaves() { let running = self.live.is_agent_live(&agent_id); if running { out.running += 1; } else { out.stopped += 1; } // Pure op: only NodeNotFound is possible, which cannot happen // since `leaf_id` came from this very tree. named.tree = named .tree .set_agent_running(leaf_id, running) .map_err(|e| AppError::Invalid(e.to_string()))?; changed = true; } } if changed { persist_doc(self.fs.as_ref(), &project, &doc).await?; } Ok(out) } }