//! Read-only project work-state read model. //! //! This module composes existing runtime state only: the project agent manifest, //! live session registries and the input mediator busy state. It performs no I/O //! beyond loading the manifest through the existing context store and creates no //! durable projection. mod actions; pub use actions::{ AttachLiveAgent, AttachLiveAgentInput, AttachLiveAgentOutput, StopLiveAgent, StopLiveAgentInput, StopLiveAgentOutput, }; use std::collections::{HashMap, HashSet}; use std::sync::Arc; use domain::input::{AgentBusyState, InputSource}; use domain::ports::AgentContextStore; use domain::{ AgentId, AgentQueueSnapshot, ConversationId, ConversationLog, ConversationTurn, Handoff, HandoffStore, InputMediator, NodeId, ProfileId, Project, ProjectPath, QueuedTicketSnapshot, SessionId, TicketId, TurnId, TurnRole, }; use crate::error::AppError; use crate::terminal::{LiveSessionKind, LiveSessionSnapshot, LiveSessions}; use crate::HandoffProvider; /// Maximum length (in characters) of a derived [`AgentTicketState::task_preview`]. /// /// The full task can be a very long prompt; the panel only needs a scannable /// excerpt, with [`AgentTicketState::task_len`] signalling there is more. const TASK_PREVIEW_MAX_CHARS: usize = 160; /// Maximum length (in characters) of a conversation [`ConversationWorkSummary::summary_preview`]. const HANDOFF_PREVIEW_MAX_CHARS: usize = 480; /// Maximum length (in characters) of a conversation [`ConversationWorkSummary::objective_preview`]. const OBJECTIVE_PREVIEW_MAX_CHARS: usize = 160; /// Maximum number of recent turns surfaced in a [`ConversationWorkSummary::recent_turns`] fallback. const RECENT_TURNS_MAX: usize = 3; /// Maximum length (in characters) of a [`ConversationTurnWorkPreview::text_preview`]. const TURN_PREVIEW_MAX_CHARS: usize = 220; /// Input for [`GetProjectWorkState::execute`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct GetProjectWorkStateInput { /// Project whose manifest provides the agent order and boundary. pub project: Project, } /// Read model for a project's current agent work state. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ProjectWorkState { /// Agents in manifest order. pub agents: Vec, /// Best-effort summaries of the conversations visible through the agents' /// tickets, joined frontend-side via `tickets[].conversation_id`. Built from /// the [`HandoffStore`] (primary) with a bounded [`ConversationLog`] fallback; /// a preview failure never blocks `agents`. pub conversations: Vec, } /// Best-effort, read-only summary of one conversation visible through the tickets. /// /// Derived live from the [`HandoffStore`] (primary source) with a bounded /// [`ConversationLog`] fallback — never a durable projection, never a repair of the /// underlying log/handoff. Joined frontend-side to tickets via /// [`ConversationWorkSummary::conversation_id`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ConversationWorkSummary { /// The conversation (pair) this summary describes. pub conversation_id: ConversationId, /// How much could be derived (see [`ConversationPreviewStatus`]). pub status: ConversationPreviewStatus, /// Bounded excerpt of the handoff objective, when present. pub objective_preview: Option, /// Bounded excerpt of the handoff summary, when a handoff is readable. pub summary_preview: Option, /// Character length of the **original** (un-truncated) handoff summary (`0` when none). pub summary_len: usize, /// Cursor (last [`TurnId`]) covered by the handoff summary, when present. pub up_to: Option, /// Bounded, recent turns from the log fallback (empty when a handoff is `Ready`). pub recent_turns: Vec, } /// How much of a [`ConversationWorkSummary`] could be derived, best-effort. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ConversationPreviewStatus { /// A handoff was present and usable: summary/objective/cursor are filled. Ready, /// No handoff yet; `recent_turns` carries the log fallback if any. Missing, /// The handoff was unreadable but the log fallback was readable. Partial, /// Neither the handoff nor the log could be read. Unavailable, } /// One recent turn, projected for the conversation-summary fallback. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ConversationTurnWorkPreview { /// Nature of the turn (prompt, response, tool activity). pub role: TurnRole, /// Origin of the turn (human operator or a delegating agent). pub source: TicketWorkSource, /// Timestamp (epoch milliseconds) of the turn. pub at_ms: u64, /// Bounded, whitespace-normalised excerpt of the turn text. pub text_preview: String, /// Character length of the **original** (un-truncated) turn text. pub text_len: usize, } /// Builds the [`ConversationLog`] bound to a given project `root`, best-effort. /// /// Twin of [`HandoffProvider`]: the work-state read model is a singleton (the project /// root arrives per call via [`GetProjectWorkStateInput`]) while the `Fs*` adapters fix /// their root at construction. This port materialises the log targeting the **right** /// folder on each call; `None` ⇒ no log fallback (zero regression for call /// sites/tests that do not wire it). Implemented in `app-tauri` (sole owner of `Fs*`). pub trait ConversationLogProvider: Send + Sync { /// Builds the [`ConversationLog`] whose persistence targets `root`, or `None`. fn conversation_log_for(&self, root: &ProjectPath) -> Option>; } /// Read model for one manifest agent. #[derive(Debug, Clone, PartialEq, Eq)] pub struct AgentWorkState { /// Agent id. pub agent_id: AgentId, /// Agent display name. pub name: String, /// Runtime profile id currently assigned to the agent. pub profile_id: ProfileId, /// Current live session, if the agent is live. pub live: Option, /// Current FIFO/busy state. pub busy: AgentBusyState, /// Pending/in-progress delegation tickets, in FIFO order. pub tickets: Vec, } /// One queued delegation ticket, projected for the work-state read model. /// /// A read-only, presentation-oriented view of a [`QueuedTicketSnapshot`]: the /// status is **derived** by crossing the FIFO snapshot with the agent's busy state /// (never stored), and `task_preview` is a bounded excerpt of the full task (the /// full text is never sent to the panel; `task_len` signals there is more). #[derive(Debug, Clone, PartialEq, Eq)] pub struct AgentTicketState { /// Stable id of the queued ticket. pub ticket_id: TicketId, /// Conversation thread this task enters. pub conversation_id: ConversationId, /// FIFO position at snapshot time (`0` = head). pub position: u32, /// Derived status (in-progress = the agent's current busy ticket). pub status: TicketWorkStatus, /// Origin of the ticket (human operator or a delegating agent). pub source: TicketWorkSource, /// Display label of the requester. pub requester_label: String, /// Bounded, whitespace-normalised excerpt of the task. pub task_preview: String, /// Character length of the **original** (un-truncated) task. pub task_len: usize, } /// Derived processing status of a queued ticket. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum TicketWorkStatus { /// The agent's current busy turn is running this ticket. InProgress, /// Waiting behind the head / the agent is idle. Queued, } /// Origin of a queued ticket, mirroring [`InputSource`] for the read model. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum TicketWorkSource { /// The human operator. Human, /// Another agent delegating via `idea_ask_agent`. Agent { /// The delegating agent. agent_id: AgentId, }, } impl From for TicketWorkSource { fn from(source: InputSource) -> Self { match source { InputSource::Human => Self::Human, InputSource::Agent { agent_id } => Self::Agent { agent_id }, } } } /// Live session coordinates exposed by the work-state read model. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct LiveWorkSession { /// The layout node currently hosting the session view. pub node_id: NodeId, /// The live session id. pub session_id: SessionId, /// Runtime family that owns the session. pub kind: LiveSessionKind, } /// Read-only use case aggregating manifest agents with live and busy state. pub struct GetProjectWorkState { contexts: Arc, live: Arc, input: Arc, queue: Arc, /// Per-root handoff source (primary), wired best-effort; `None` ⇒ no summaries. handoffs: Option>, /// Per-root log source (fallback), wired best-effort; `None` ⇒ no fallback turns. logs: Option>, } impl GetProjectWorkState { /// Builds the read-model use case from existing stores/registries. /// /// Conversation summaries are off by default; wire them with /// [`Self::with_conversation_sources`]. #[must_use] pub fn new( contexts: Arc, live: Arc, input: Arc, queue: Arc, ) -> Self { Self { contexts, live, input, queue, handoffs: None, logs: None, } } /// Wires the best-effort conversation-summary sources (Lot C): the handoff /// store (primary) and the conversation log (bounded fallback), both resolved /// per project root. Builder so existing call sites/tests stay unchanged. #[must_use] pub fn with_conversation_sources( mut self, handoffs: Arc, logs: Arc, ) -> Self { self.handoffs = Some(handoffs); self.logs = Some(logs); self } /// Executes the read-only aggregation. /// /// # Errors /// - [`AppError::Store`] when the manifest cannot be loaded, /// - [`AppError::Invalid`] if a manifest entry violates agent invariants. pub async fn execute( &self, input: GetProjectWorkStateInput, ) -> Result { let manifest = self.contexts.load_manifest(&input.project).await?; let live_by_agent = live_by_agent(self.live.live_agent_snapshots()); let agents = manifest .entries .iter() .map(|entry| { let agent = entry .to_agent() .map_err(|err| AppError::Invalid(err.to_string()))?; let live = live_by_agent .get(&agent.id) .map(|snapshot| LiveWorkSession { node_id: snapshot.node_id, session_id: snapshot.session_id, kind: snapshot.kind, }); // Tickets are crossed with the busy state: only an agent absent from // the manifest is dropped (this loop only visits manifest entries), so // the manifest boundary is naturally preserved. let busy_ticket = self.input.busy_state(agent.id).ticket(); let tickets = self .queue .queue_for(agent.id) .into_iter() .map(|snapshot| ticket_state(snapshot, busy_ticket)) .collect(); Ok(AgentWorkState { agent_id: agent.id, name: agent.name, profile_id: agent.profile_id, live, busy: self.input.busy_state(agent.id), tickets, }) }) .collect::, AppError>>()?; // Lot C — best-effort conversation summaries. Only the conversations // visible through the projected tickets are summarised, deduplicated in // first-seen order. A preview failure degrades the per-conversation status // (never an `AppError`): `agents`/live/busy/tickets stay intact above. let conversations = self .summarise_conversations(&input.project.root, &agents) .await; Ok(ProjectWorkState { agents, conversations, }) } /// Derives the best-effort summaries of the conversations referenced by the /// projected tickets. Resolves the per-root handoff/log stores once, then folds /// each distinct conversation id through [`conversation_summary`]. async fn summarise_conversations( &self, root: &ProjectPath, agents: &[AgentWorkState], ) -> Vec { let conversation_ids = distinct_conversation_ids(agents); if conversation_ids.is_empty() { return Vec::new(); } let handoff_store = self .handoffs .as_ref() .and_then(|provider| provider.handoff_store_for(root)); let log_store = self .logs .as_ref() .and_then(|provider| provider.conversation_log_for(root)); let mut summaries = Vec::with_capacity(conversation_ids.len()); for conversation in conversation_ids { summaries.push(conversation_summary(conversation, &handoff_store, &log_store).await); } summaries } } /// Collects the conversation ids referenced by the agents' tickets, deduplicated in /// first-seen (FIFO) order so the summary list mirrors the panel's ordering. fn distinct_conversation_ids(agents: &[AgentWorkState]) -> Vec { let mut seen = HashSet::new(); let mut ids = Vec::new(); for agent in agents { for ticket in &agent.tickets { if seen.insert(ticket.conversation_id) { ids.push(ticket.conversation_id); } } } ids } /// Derives one conversation summary, best-effort, following the Lot C algorithm: /// handoff present ⇒ `Ready`; absent ⇒ `Missing` (+ log fallback); unreadable but /// log readable ⇒ `Partial`; both KO ⇒ `Unavailable`. Never returns an error. async fn conversation_summary( conversation: ConversationId, handoff_store: &Option>, log_store: &Option>, ) -> ConversationWorkSummary { // A handoff store absent from the wiring is treated as "no handoff yet" // (`Missing` path), never as an error: the read model stays best-effort. let handoff = match handoff_store { Some(store) => Some(store.load(conversation).await), None => None, }; match handoff { Some(Ok(Some(handoff))) => ready_summary(conversation, &handoff), Some(Ok(None)) | None => { // Handoff legitimately absent: status stays Missing even if the log // fallback is itself unreadable (then simply no recent turns). let recent_turns = recent_turns(log_store, conversation) .await .unwrap_or_default(); ConversationWorkSummary { conversation_id: conversation, status: ConversationPreviewStatus::Missing, objective_preview: None, summary_preview: None, summary_len: 0, up_to: None, recent_turns, } } Some(Err(_)) => match recent_turns(log_store, conversation).await { // Handoff unreadable but the log answered: partial view from the turns. Some(recent_turns) => ConversationWorkSummary { conversation_id: conversation, status: ConversationPreviewStatus::Partial, objective_preview: None, summary_preview: None, summary_len: 0, up_to: None, recent_turns, }, // Both sources failed: nothing usable to show. None => ConversationWorkSummary { conversation_id: conversation, status: ConversationPreviewStatus::Unavailable, objective_preview: None, summary_preview: None, summary_len: 0, up_to: None, recent_turns: Vec::new(), }, }, } } /// Projects a readable [`Handoff`] into a `Ready` summary with bounded previews. fn ready_summary(conversation: ConversationId, handoff: &Handoff) -> ConversationWorkSummary { ConversationWorkSummary { conversation_id: conversation, status: ConversationPreviewStatus::Ready, objective_preview: handoff .objective .as_deref() .map(|objective| preview(objective, OBJECTIVE_PREVIEW_MAX_CHARS)), summary_preview: Some(preview(&handoff.summary_md, HANDOFF_PREVIEW_MAX_CHARS)), summary_len: handoff.summary_md.chars().count(), up_to: Some(handoff.up_to), recent_turns: Vec::new(), } } /// Reads the bounded recent-turns fallback for `conversation`. /// /// Returns `Some(turns)` (possibly empty) when a log store is wired and answers, /// `None` when there is no log store or the read failed — the caller uses that /// distinction to separate `Partial` from `Unavailable`. Never reads the full thread /// (only [`ConversationLog::last`]) and re-caps defensively to [`RECENT_TURNS_MAX`]. async fn recent_turns( log_store: &Option>, conversation: ConversationId, ) -> Option> { let store = log_store.as_ref()?; match store.last(conversation, RECENT_TURNS_MAX).await { Ok(turns) => Some( turns .into_iter() .take(RECENT_TURNS_MAX) .map(turn_preview) .collect(), ), Err(_) => None, } } /// Projects one [`ConversationTurn`] into its bounded read-model preview. fn turn_preview(turn: ConversationTurn) -> ConversationTurnWorkPreview { ConversationTurnWorkPreview { role: turn.role, source: turn.source.into(), at_ms: turn.at_ms, text_preview: preview(&turn.text, TURN_PREVIEW_MAX_CHARS), text_len: turn.text.chars().count(), } } /// Projects one FIFO snapshot into a read-model ticket, deriving status from the /// agent's current busy ticket (the one running its turn = `InProgress`). fn ticket_state(snapshot: QueuedTicketSnapshot, busy_ticket: Option) -> AgentTicketState { let status = if busy_ticket == Some(snapshot.id) { TicketWorkStatus::InProgress } else { TicketWorkStatus::Queued }; AgentTicketState { ticket_id: snapshot.id, conversation_id: snapshot.conversation, position: snapshot.position, status, source: snapshot.source.into(), requester_label: snapshot.requester, task_preview: task_preview(&snapshot.task), task_len: snapshot.task.chars().count(), } } /// Builds the bounded task excerpt for the UI panel (see [`preview`]). /// /// The original length is reported separately as [`AgentTicketState::task_len`]. fn task_preview(task: &str) -> String { preview(task, TASK_PREVIEW_MAX_CHARS) } /// Builds a bounded, whitespace-normalised excerpt of `text` for the UI. /// /// Trims, collapses any run of whitespace to a single space, then truncates to /// `max_chars` characters (never mid-codepoint). The original length is reported /// separately by each caller's `*_len` field. fn preview(text: &str, max_chars: usize) -> String { let normalised = text.split_whitespace().collect::>().join(" "); if normalised.chars().count() <= max_chars { normalised } else { normalised.chars().take(max_chars).collect() } } fn live_by_agent(snapshots: Vec) -> HashMap { let mut out = HashMap::new(); for snapshot in snapshots { out.entry(snapshot.agent_id).or_insert(snapshot); } out }