//! [`AppError`] — the single error type returned by every use case. //! //! Per-port errors from the domain ([`domain::ports::FsError`], //! [`domain::ports::StoreError`], …) are mapped into this application-level //! error so that the presentation layer (Tauri commands) only ever has to deal //! with one error shape when building its `ErrorDTO`. use domain::ports::{ AgentSessionError, EmbedderError, FsError, GitError, MemoryError, ProcessError, PtyError, RemoteError, RuntimeError, StoreError, }; use domain::{AgentId, NodeId}; use domain::{IssueStoreError, SprintStoreError}; /// Errors surfaced by application use cases. /// /// Each variant carries a stable, machine-readable `code` (see [`AppError::code`]) /// so the presentation layer can map it to an `ErrorDTO` without string matching. #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] pub enum AppError { /// A requested resource was not found. #[error("not found: {0}")] NotFound(String), /// The input failed a domain/application invariant. #[error("invalid input: {0}")] Invalid(String), /// A filesystem operation failed. #[error("filesystem error: {0}")] FileSystem(String), /// A persistence (store) operation failed. #[error("store error: {0}")] Store(String), /// A process/PTY/runtime operation failed. #[error("process error: {0}")] Process(String), /// A git operation failed. #[error("git error: {0}")] Git(String), /// A remote (SSH/WSL) operation failed. #[error("remote error: {0}")] Remote(String), /// An agent is already running in a live cell and cannot be launched again /// (the "one live session per agent" invariant). Reuse goes through /// templates (instantiate → distinct agents); IdeA never multi-instances a /// single agent. #[error("agent {agent_id} is already running in cell {node_id}")] AgentAlreadyRunning { /// The agent that is already live. agent_id: AgentId, /// The layout cell (leaf) currently hosting that agent's live session. node_id: NodeId, }, /// The delegation target finished its turn and **returned to its prompt without /// calling `idea_reply`**, so the rendezvous could produce no answer. Surfaced /// promptly (after a short grace window) instead of blocking the caller until the /// long safety timeout. **Retryable**: the caller may re-ask, ideally reminding the /// target to render its result via `idea_reply` (never plain text). Carries the /// target's display name. #[error( "agent {0} returned to its prompt without calling idea_reply (no answer was \ rendered); retry the request and ensure the agent replies via idea_reply" )] TargetReturnedNoReply(String), /// The delegation target is **still actively working** (its transcript kept /// growing across liveness probes) but the rendezvous **absolute ceiling** was /// reached, so the synchronous wait was abandoned. **Distinct** from /// [`Self::TargetReturnedNoReply`]: the target is *not* silent, so a blind retry /// would stack a second heavy turn on a target already mid-task. **Not** a mute /// timeout — the caller should check the target's in-progress work/branch (it may /// still finish and call `idea_reply`) before re-soliciting lightly. Carries the /// target's display name. #[error( "target {0} is still actively working but the rendezvous ceiling was reached; \ do not retry blindly — check the target's in-progress work/branch (it may still \ finish and call idea_reply), then re-solicit lightly if needed" )] TargetCeilingActive(String), /// An unexpected internal error. #[error("internal error: {0}")] Internal(String), } impl AppError { /// A stable, machine-readable code for this error, intended for the /// `ErrorDTO` so the frontend can branch without parsing messages. #[must_use] pub fn code(&self) -> &'static str { match self { Self::NotFound(_) => "NOT_FOUND", Self::Invalid(_) => "INVALID", Self::FileSystem(_) => "FILESYSTEM", Self::Store(_) => "STORE", Self::Process(_) => "PROCESS", Self::Git(_) => "GIT", Self::Remote(_) => "REMOTE", Self::AgentAlreadyRunning { .. } => "AGENT_ALREADY_RUNNING", Self::TargetReturnedNoReply(_) => "TARGET_RETURNED_NO_REPLY", Self::TargetCeilingActive(_) => "RENDEZVOUS_CEILING_ACTIVE", Self::Internal(_) => "INTERNAL", } } } impl From for AppError { fn from(e: FsError) -> Self { match e { FsError::NotFound(p) => Self::NotFound(p), other => Self::FileSystem(other.to_string()), } } } impl From for AppError { fn from(e: StoreError) -> Self { match e { StoreError::NotFound => Self::NotFound("store item".to_owned()), other => Self::Store(other.to_string()), } } } impl From for AppError { fn from(e: IssueStoreError) -> Self { match e { IssueStoreError::NotFound => Self::NotFound("issue".to_owned()), IssueStoreError::VersionConflict { .. } => Self::Invalid(e.to_string()), IssueStoreError::Invalid(message) => Self::Invalid(message), IssueStoreError::Store(message) => Self::Store(message), } } } impl From for AppError { fn from(e: SprintStoreError) -> Self { match e { SprintStoreError::NotFound => Self::NotFound("sprint".to_owned()), SprintStoreError::VersionConflict { .. } => Self::Invalid(e.to_string()), SprintStoreError::Invalid(message) => Self::Invalid(message), SprintStoreError::Store(message) => Self::Store(message), } } } impl From for AppError { fn from(e: MemoryError) -> Self { match e { MemoryError::NotFound => Self::NotFound("memory note".to_owned()), MemoryError::Frontmatter(m) => Self::Invalid(m), other => Self::Store(other.to_string()), } } } impl From for AppError { /// Maps to [`AppError::Store`] — an embedder is a *derived* recall detail; its /// failure must degrade the recall (fallback to naïve), never fail hard. This /// mapping exists for completeness; recall adapters degrade *before* an /// embedder error ever reaches a use case. fn from(e: EmbedderError) -> Self { Self::Store(e.to_string()) } } impl From for AppError { fn from(e: PtyError) -> Self { Self::Process(e.to_string()) } } impl From for AppError { fn from(e: ProcessError) -> Self { Self::Process(e.to_string()) } } impl From for AppError { fn from(e: RuntimeError) -> Self { Self::Process(e.to_string()) } } impl From for AppError { fn from(e: GitError) -> Self { Self::Git(e.to_string()) } } impl From for AppError { fn from(e: RemoteError) -> Self { Self::Remote(e.to_string()) } } impl From for AppError { /// Maps a structured [`AgentSessionError`] (ARCHITECTURE §17.1) onto the single /// application error shape. `Start`/`Io`/`Decode`/`Timeout` are all execution /// failures of a live structured session (a process/SDK conversation), so they /// fold into [`AppError::Process`] — coherent with [`PtyError`]/[`ProcessError`], /// the byte-stream twins. The raw CLI JSON never travels through `Decode` (the /// adapter already redacted it), so this is safe to surface. fn from(e: AgentSessionError) -> Self { Self::Process(e.to_string()) } } #[cfg(test)] mod tests { use super::*; /// La cible-active-au-plafond porte un code **stable et distinct** du no-reply /// (l'appelant peut brancher sans parser le message) et un message « ne retente /// pas à l'aveugle » différent du retour-au-prompt-sans-reply. #[test] fn ceiling_active_code_is_stable_and_distinct() { let ceiling = AppError::TargetCeilingActive("architect".to_owned()); assert_eq!(ceiling.code(), "RENDEZVOUS_CEILING_ACTIVE"); let no_reply = AppError::TargetReturnedNoReply("architect".to_owned()); assert_eq!(no_reply.code(), "TARGET_RETURNED_NO_REPLY"); // Deux issues TYPÉES distinctes (le cœur du cadrage Architect) : un plafond // atteint sur cible active n'est jamais confondu avec un silence/no-reply. assert_ne!(ceiling.code(), no_reply.code()); // Et distinct d'un timeout muet (`Process` via AgentSessionError::Timeout). assert_ne!( ceiling.code(), AppError::from(AgentSessionError::Timeout).code() ); // Le message guide explicitement vers « ne pas retenter à l'aveugle ». assert!(ceiling.to_string().contains("do not retry blindly")); } }