//! Ports (traits) — the boundary the domain defines and the infrastructure //! implements (the **D** of SOLID, materialised). //! //! # Async decision //! //! Ports that touch the outside world (PTY, filesystem, process, stores, git, //! remote connect) are inherently asynchronous in every realistic adapter //! (tokio fs/process, russh, sftp). We therefore make those traits `async` via //! [`async_trait`]. The rationale for `#[async_trait]` over native //! `async fn` in traits / `-> impl Future`: //! //! - These ports are consumed as **trait objects** (`Arc`, //! injected at the composition root). Native `async fn` in traits is not yet //! dyn-compatible without boxing, so `async_trait` (which boxes the future) //! is the pragmatic, stable choice and keeps the call sites object-safe. //! - It keeps signatures readable and uniform across all adapters. //! //! Purely synchronous, non-blocking ports ([`Clock`], [`IdGenerator`], //! [`EventBus`], the CPU-bound part of [`AgentRuntime`]) stay plain `fn` — no //! need to pay the boxing cost. //! //! Each port is **fine-grained** (Interface Segregation): `FileSystem`, //! `PtyPort`, `ProcessSpawner` are separate, never a `System` god-trait. use std::sync::Arc; use async_trait::async_trait; use thiserror::Error; use crate::agent::AgentManifest; use crate::agent_tool_policy::AgentToolPolicy; use crate::background_task::{ BackgroundTask, BackgroundTaskKind, BackgroundTaskResult, BackgroundTaskWakePolicy, }; use crate::events::DomainEvent; use crate::ids::{AgentId, NodeId, ProjectId, ScheduleId, SessionId, SprintId, TaskId}; use crate::issue::{ Issue, IssueCarnet, IssueIndexEntry, IssueListFilter, IssueNumber, IssueRef, IssueVersion, }; use crate::markdown::MarkdownDoc; use crate::memory::{Memory, MemoryIndexEntry, MemoryLink, MemorySlug}; use crate::permission::ProjectPermissions; use crate::profile::{AgentProfile, EmbedderProfile}; use crate::project::{Project, ProjectPath}; use crate::remote::RemoteKind; use crate::skill::{Skill, SkillScope}; use crate::sprint::{Sprint, SprintIndexEntry, SprintVersion}; use crate::template::AgentTemplate; use crate::terminal::PtySize; // --------------------------------------------------------------------------- // Support value types shared across ports // --------------------------------------------------------------------------- /// How the `.md` context should be delivered to a spawned process, resolved /// from a profile's [`crate::profile::ContextInjection`]. #[derive(Debug, Clone, PartialEq, Eq)] pub enum ContextInjectionPlan { /// Materialise the context at a (relative) file path before launch. File { /// Relative target path inside the cwd. target: String, }, /// Append the given already-rendered arguments to the command line. Args { /// Extra arguments carrying the context path. args: Vec, }, /// Pipe the content on stdin. Stdin, /// Provide the content (or its path) via an environment variable. Env { /// Variable name. var: String, }, } /// Intention de session pour un lancement d'agent donné. #[derive(Debug, Clone, PartialEq, Eq)] pub enum SessionPlan { /// Pas de reprise : profil sans bloc `session`, ou cellule neuve sans id. None, /// Premier lancement : IdeA a généré `conversation_id`, à assigner via assign_flag. Assign { /// Identifiant de conversation généré par IdeA pour ce lancement. conversation_id: String, }, /// Réouverture : reprendre la conversation existante via resume_flag. Resume { /// Identifiant de la conversation existante à reprendre. conversation_id: String, }, } /// A fully-resolved process invocation: command, args, cwd, environment, and the /// plan for delivering the agent context. #[derive(Debug, Clone, PartialEq, Eq)] pub struct SpawnSpec { /// Executable to run. pub command: String, /// Arguments. pub args: Vec, /// Working directory. pub cwd: ProjectPath, /// Extra environment variables. pub env: Vec<(String, String)>, /// How the context is injected, if any. pub context_plan: Option, /// OS-sandbox plan to enforce on the spawned process (lot LP4). `None` ⇒ no /// enforcement (the agent runs natively); the field is wired but unused until /// the Landlock adapter (LP4-1) and launch path (LP4-2) consume it. pub sandbox: Option, } /// The agent context prepared for injection (content + the on-disk path it maps /// to within the project). #[derive(Debug, Clone, PartialEq, Eq)] pub struct PreparedContext { /// Rendered Markdown content. pub content: MarkdownDoc, /// Relative path of the `.md` inside the project. pub relative_path: String, /// Absolute root of the project this context belongs to. pub project_root: String, } /// Errors returned while preparing the IdeA-owned ticket assistant context. #[derive(Debug, Clone, PartialEq, Eq, Error)] pub enum AssistantContextError { /// The context source was missing and no embedded default could be used. #[error("assistant context not found")] NotFound, /// The context could not be read or rendered. #[error("assistant context store failed: {0}")] Store(String), } /// Provides the IdeA-owned context used by ticket assistant sessions. #[async_trait] pub trait AssistantContextProvider: Send + Sync { /// Renders a prepared context for a ticket assistant bound to `issue`. /// /// # Errors /// [`AssistantContextError`] when the context cannot be read or rendered. async fn prepare_ticket_assistant_context( &self, project: &Project, issue: &Issue, ) -> Result; } /// Stores requester-scoped MCP tool policies for ephemeral assistant sessions. pub trait AgentToolPolicyStore: Send + Sync { /// Sets or replaces the policy for `requester`. fn set_policy(&self, requester: String, policy: AgentToolPolicy); /// Returns the policy for `requester`, if any. fn get_policy(&self, requester: &str) -> Option; /// Clears the policy for `requester`. fn clear_policy(&self, requester: &str); } /// Enriched, **best-effort** details about a conversation, specific to a CLI's /// on-disk transcript format (CONTEXT §T6). /// /// Every field is optional: the core never *requires* this data, and a missing /// piece (or a missing inspector) must never block a resume. The values exist /// purely to enrich a resume popup (last topic, token indicator). #[derive(Debug, Clone, PartialEq, Eq)] pub struct ConversationDetails { /// A short, best-effort label for the conversation (e.g. the last user /// message, truncated). `None` when it cannot be extracted. pub last_topic: Option, /// A best-effort cumulative token count for the conversation. `None` when no /// usage information is available. pub token_count: Option, } /// An opaque handle to a live PTY, owned by the adapter. /// /// The domain only needs an identity to address the PTY in subsequent calls; /// the actual OS handle lives in infrastructure. #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct PtyHandle { /// The session this PTY backs. pub session_id: SessionId, } /// Exit status of a process. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct ExitStatus { /// Exit code (`None` if terminated by a signal). pub code: Option, } /// Captured output of a non-interactive process run. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Output { /// Exit status. pub status: ExitStatus, /// Captured stdout. pub stdout: Vec, /// Captured stderr. pub stderr: Vec, } /// A location-neutral path used by [`FileSystem`] (local, SFTP, or WSL). #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct RemotePath(pub String); impl RemotePath { /// Wraps a raw path. #[must_use] pub fn new(p: impl Into) -> Self { Self(p.into()) } /// Returns the path as a string slice. #[must_use] pub fn as_str(&self) -> &str { &self.0 } } /// A single directory entry returned by [`FileSystem::list`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct DirEntry { /// Entry name (basename). pub name: String, /// Whether the entry is a directory. pub is_dir: bool, } /// An owned, boxed stream of PTY output chunks. /// /// Concrete adapters decide the underlying transport; the domain only sees a /// dynamically-dispatched iterator of byte chunks. pub type OutputStream = Box> + Send>; /// A boxed stream of domain events, returned by [`EventBus::subscribe`]. pub type EventStream = Box + Send>; /// A boxed stream of background task completions. pub type BackgroundCompletionStream = Box + Send>; /// Specification passed to a background task runner. #[derive(Debug, Clone, PartialEq, Eq)] pub struct BackgroundTaskSpec { /// Task id allocated by the application. pub task_id: TaskId, /// Owning project. pub project_id: ProjectId, /// Agent that owns completion delivery. pub owner_agent_id: AgentId, /// Kind-specific task payload. pub kind: BackgroundTaskKind, /// Completion wake policy. pub wake_policy: BackgroundTaskWakePolicy, /// Optional command invocation for command-backed tasks. pub command: Option, /// Optional absolute deadline, epoch milliseconds. pub deadline_ms: Option, } /// Opaque runner handle for a spawned background task. #[derive(Debug, Clone, PartialEq, Eq)] pub struct BackgroundTaskHandle { /// Spawned task id. pub task_id: TaskId, } /// Terminal completion reported by a background runner. #[derive(Debug, Clone, PartialEq, Eq)] pub struct BackgroundTaskCompletion { /// Completed task id. pub task_id: TaskId, /// Terminal result. pub result: BackgroundTaskResult, } /// Why IdeA wakes an agent outside a human/delegation submit. #[derive(Debug, Clone, PartialEq, Eq)] pub enum WakeReason { /// A persisted background task completion is ready in the owner's inbox. BackgroundCompletion { /// Completed background task. task_id: TaskId, }, /// A generic queued inbox item should be processed. InboxItem { /// Queued item id. item_id: crate::mailbox::TicketId, }, } /// Un événement incrémental d'un tour de réponse d'un agent IA (ARCHITECTURE §17.1). /// /// Universel : l'adapter (Claude/Codex) traduit SON format structuré documenté /// vers ces variantes ; **aucun** détail propre à une CLI (pas de `stream-json`, /// pas de `--output-format`, pas de chemin de transcript) ne franchit cette /// frontière domaine. #[derive(Debug, Clone, PartialEq, Eq)] pub enum ReplyEvent { /// Un fragment de texte assistant (rendu incrémental côté UI chat). TextDelta { /// Le fragment de texte. text: String, }, /// Une activité d'outil de l'agent (best-effort, pour l'observabilité chat : /// « lit un fichier », « lance une commande »). Le `label` est déjà /// humain-lisible ; le détail brut reste dans l'adapter. ToolActivity { /// Libellé humain-lisible de l'activité. label: String, }, /// Message assistant intermédiaire complet, destiné à l'observabilité live d'un /// rendez-vous inter-agent. Non terminal : seul [`ReplyEvent::Final`] clôt le tour. Announcement { /// Texte complet de l'annonce. text: String, }, /// **Preuve de vivacité non terminale** (model-agnostique) : l'agent est /// toujours en train de travailler. Émis par l'adapter quand le moteur signale /// un battement de cœur natif **sans contenu utile** (handshake/init, fenêtre de /// limite de débit, début/fin de tour côté moteur…). Sert à distinguer « agent /// vivant mais lent » d'« agent bloqué » (readiness/heartbeat, lot 1). /// /// **Jamais terminal** : un `Heartbeat` ne clôt **pas** le flux — il s'intercale /// comme un delta (ignoré par les consommateurs synchrones) et le flux continue /// jusqu'au [`ReplyEvent::Final`]. Un tour comporte ≥0 `Heartbeat`, jamais /// d'obligation d'en émettre. Heartbeat, /// **Limite de session/débit atteinte** (model-agnostique, ARCHITECTURE §21) : /// l'adapter structuré a observé que le moteur a suspendu l'agent pour cause de /// quota (Claude `rate_limit_event` → `rate_limit_info.resetsAt`, ou équivalent). /// Porte un fait neutre : « limité, reset à T (peut-être) ». Aucun détail propre /// à une CLI ne franchit la frontière (forme du `rate_limit_event`, format de /// l'heure…) — tout cela reste confiné à l'adapter (cf. §21.2-T2). /// /// **Jamais terminal** (cf. §21.2-T4) : exactement comme [`ReplyEvent::Heartbeat`], /// un `RateLimited` **ne clôt pas** le flux — il s'intercale et le flux continue /// jusqu'au [`ReplyEvent::Final`] **ou** jusqu'à une clôture du flux. Conséquence /// pour les consommateurs : un tour clos **sans `Final`** parce que limité doit /// être traité comme une **fin gracieuse limitée** (et non comme une erreur « flux /// clos sans Final »), dès lors qu'un `RateLimited` a été vu dans le tour. RateLimited { /// Instant de réinitialisation de la limite, en **époche-millisecondes** /// (homogène avec [`Clock::now_millis`]). `None` quand le moteur n'a pas /// fourni d'heure de reset exploitable ⇒ pas de reprise auto possible (filet /// humain, §21.1 niveau 3). Jamais une `Instant` monotone (cf. §21.2-T1). resets_at_ms: Option, }, /// **Événement terminal déterministe** d'un tour : l'adapter l'émet quand il a /// lu le message `result` documenté de la CLI. Porte le contenu final agrégé. /// Après `Final`, le flux se termine (plus aucun événement). Final { /// Le contenu final agrégé du tour. content: String, }, } /// Flux borné d'événements de réponse d'UN tour (ARCHITECTURE §17.1). Se termine /// après le [`ReplyEvent::Final`] (ou sur erreur). Calqué sur [`OutputStream`], /// mais **typé** : deltas de texte → activités d'outil → battements de cœur* /// ([`ReplyEvent::Heartbeat`], non terminaux, en nombre quelconque et entrelacés) → /// **un** `Final` terminal déterministe, plutôt que des octets bruts. Seul le `Final` /// clôt le flux ; deltas, activités et heartbeats sont tous non terminaux. pub type ReplyStream = Box + Send>; /// Intention **model-agnostique** exécutée à l'échéance d'un réveil de /// [`Scheduler`] (ARCHITECTURE §21.4). /// /// **Donnée pure, pas de closure** : à l'image du dispatch de l'orchestrateur /// (§14.3, où une requête est une *donnée* validée puis dispatchée), une tâche /// programmée est une **valeur** qui franchit la frontière, jamais un effet ni une /// fonction. C'est ce qui garde le port [`Scheduler`] sans aucune dépendance vers /// l'application : l'adapter ne fait que **remettre** cette donnée à un drain /// applicatif, qui seul l'exécute. /// /// Enum **extensible** : d'autres intentions programmées pourront s'ajouter sans /// toucher au port (Open/Closed). #[derive(Debug, Clone, PartialEq, Eq)] pub enum ScheduledTask { /// Reprendre un agent à l'heure de reset de sa limite de session (§21) : relancer /// via [`SessionPlan::Resume`] avec un prompt de reprise court (logique côté /// application, lot LS4). Porte exactement le pivot de reprise model-agnostique. ResumeAgent { /// L'agent à reprendre. agent_id: AgentId, /// La cellule (nœud du layout) qui héberge sa session. node_id: NodeId, /// Id de conversation du moteur à reprendre (`None` ⇒ reprise dégradée sans /// id, comme le reste du chemin de reprise §15). conversation_id: Option, }, } // --------------------------------------------------------------------------- // Per-port error types // --------------------------------------------------------------------------- /// Errors from [`AgentRuntime`]. #[derive(Debug, Clone, PartialEq, Eq, Error)] pub enum RuntimeError { /// The configured command could not be resolved/detected. #[error("agent runtime: {0}")] Detection(String), /// The invocation could not be prepared (bad profile, etc.). #[error("agent runtime: invalid invocation: {0}")] Invocation(String), } /// Errors from [`PtyPort`]. #[derive(Debug, Clone, PartialEq, Eq, Error)] pub enum PtyError { /// Failed to spawn the PTY. #[error("pty spawn failed: {0}")] Spawn(String), /// Read/write failure. #[error("pty io failed: {0}")] Io(String), /// The handle referred to no live PTY. #[error("pty handle not found")] NotFound, } /// Errors from an [`AgentSession`] / [`AgentSessionFactory`] (ARCHITECTURE §17.1). /// /// Frontière nette : on ne propage **jamais** le JSON brut d'une CLI à travers /// ces erreurs (cf. [`AgentSessionError::Decode`]). #[derive(Debug, Clone, PartialEq, Eq, Error)] pub enum AgentSessionError { /// La session programmatique n'a pas pu démarrer (CLI introuvable, mode /// structuré indisponible, handshake invalide). #[error("agent session start failed: {0}")] Start(String), /// Échec d'envoi/de communication avec la session vivante. #[error("agent session io failed: {0}")] Io(String), /// La sortie structurée de la CLI n'a pas pu être décodée (JSON cassé, schéma /// inattendu). On ne propage jamais le JSON brut. #[error("agent session decode failed: {0}")] Decode(String), /// `send_blocking` n'a pas observé de [`ReplyEvent::Final`] dans le temps /// imparti. La session **reste vivante** (on ne tue rien) ; l'appelant décide. #[error("agent session reply timed out")] Timeout, } /// Errors from [`ProcessSpawner`]. #[derive(Debug, Clone, PartialEq, Eq, Error)] pub enum ProcessError { /// The process could not be started. #[error("process spawn failed: {0}")] Spawn(String), /// I/O failure while running. #[error("process io failed: {0}")] Io(String), } /// Errors from [`FileSystem`]. #[derive(Debug, Clone, PartialEq, Eq, Error)] pub enum FsError { /// Path not found. #[error("path not found: {0}")] NotFound(String), /// Permission denied. #[error("permission denied: {0}")] PermissionDenied(String), /// Other I/O error. #[error("filesystem io failed: {0}")] Io(String), } /// Errors from persistence stores ([`TemplateStore`], [`ProjectStore`], /// [`AgentContextStore`], [`SkillStore`]). #[derive(Debug, Clone, PartialEq, Eq, Error)] pub enum StoreError { /// The requested item was not found. #[error("not found")] NotFound, /// (De)serialisation failed. #[error("serialization failed: {0}")] Serialization(String), /// Underlying I/O error. #[error("store io failed: {0}")] Io(String), } /// Errors from the [`MemoryStore`]. #[derive(Debug, Clone, PartialEq, Eq, Error)] pub enum MemoryError { /// The requested note was not found. #[error("memory not found")] NotFound, /// A note's frontmatter could not be parsed or validated. #[error("memory frontmatter error: {0}")] Frontmatter(String), /// Underlying I/O error. #[error("memory io failed: {0}")] Io(String), /// (De)serialisation of the index or another structured part failed. #[error("memory serialization failed: {0}")] Serialization(String), } /// Errors from an [`Embedder`] (LOT C, étage 2 vectoriel). /// /// Best-effort by contract: a [`MemoryRecall`] that composes an embedder must /// **degrade**, never fail hard, on any of these — an unavailable engine or an /// unimplemented strategy maps to a fallback on the naïve recall (it must never /// surface as a hard recall error). See [`Embedder`] and the `VectorMemoryRecall` /// / `AdaptiveMemoryRecall` adapters. #[derive(Debug, Clone, PartialEq, Eq, Error)] pub enum EmbedderError { /// The embedding engine is not installed / not reachable (e.g. a local ONNX /// model file is missing, or a remote embedding server / API is unreachable). #[error("embedder unavailable: {0}")] Unavailable(String), /// The requested strategy is not implemented yet (the concrete `localOnnx` / /// `localServer` / `api` backends ship as documented stubs returning this — /// never a panic). Real ONNX/HTTP integration is an explicit follow-up. #[error("embedder strategy unsupported: {0}")] Unsupported(String), /// An I/O failure while producing embeddings. #[error("embedder io failed: {0}")] Io(String), } /// Errors from [`RemoteHost`]. #[derive(Debug, Clone, PartialEq, Eq, Error)] pub enum RemoteError { /// Connection failed. #[error("remote connection failed: {0}")] Connection(String), /// Authentication failed. #[error("remote authentication failed: {0}")] Auth(String), } /// Errors from a [`SessionInspector`]. /// /// Inspection is **best-effort and optional** (CONTEXT §T6/T7): these errors /// must never block a conversation resume. A caller routing several inspectors /// simply treats any error as "no enriched details available". #[derive(Debug, Clone, PartialEq, Eq, Error)] pub enum InspectError { /// No transcript was found for the requested conversation. #[error("conversation transcript not found")] NotFound, /// The transcript could not be read. #[error("conversation transcript read failed: {0}")] Read(String), } /// Errors from the git [`GitPort`]. #[derive(Debug, Clone, PartialEq, Eq, Error)] pub enum GitError { /// The repository was not found / not initialised. #[error("git repository not found")] NotFound, /// A git operation failed. #[error("git operation failed: {0}")] Operation(String), } /// Errors from first-class background task ports. #[derive(Debug, Clone, PartialEq, Eq, Error)] pub enum BackgroundTaskPortError { /// The requested task was not found. #[error("background task not found")] NotFound, /// A task with this id already exists. #[error("background task already exists")] AlreadyExists, /// The task or request is invalid. #[error("background task invalid: {0}")] Invalid(String), /// The runner failed. #[error("background task runner failed: {0}")] Runner(String), /// The store failed. #[error("background task store failed: {0}")] Store(String), } /// Errors from the owner wake port. #[derive(Debug, Clone, PartialEq, Eq, Error)] pub enum WakeError { /// The owner is already running a turn; the inbox item must stay queued. #[error("agent {agent_id} is busy; wake skipped")] AgentBusy { /// Target agent. agent_id: AgentId, }, /// There is no queued item to drain for this wake. #[error("no pending inbox item for agent {agent_id}")] NoPendingItem { /// Target agent. agent_id: AgentId, }, /// The drained inbox item does not match the requested wake reason. #[error("unexpected inbox item for wake: {0}")] UnexpectedInboxItem(String), /// Session lookup or launch failed. #[error("wake session failed: {0}")] Session(String), /// Background task store failed. #[error("wake store failed: {0}")] Store(String), /// Background task payload was missing or invalid for wake delivery. #[error("wake task invalid: {0}")] Task(String), } /// Errors from the issue store. #[derive(Debug, Clone, PartialEq, Eq, Error)] pub enum IssueStoreError { /// The requested issue does not exist. #[error("issue not found")] NotFound, /// Optimistic concurrency conflict. #[error("issue version conflict: expected {expected}, actual {actual}")] VersionConflict { /// Expected version. expected: IssueVersion, /// Actual persisted version. actual: IssueVersion, }, /// The issue payload is invalid. #[error("issue invalid: {0}")] Invalid(String), /// Store I/O or serialization failed. #[error("issue store failed: {0}")] Store(String), } /// Errors from the sprint store. #[derive(Debug, Clone, PartialEq, Eq, Error)] pub enum SprintStoreError { /// The requested sprint does not exist. #[error("sprint not found")] NotFound, /// Optimistic concurrency conflict. #[error("sprint version conflict: expected {expected}, actual {actual}")] VersionConflict { /// Expected version. expected: SprintVersion, /// Actual persisted version. actual: SprintVersion, }, /// The sprint payload is invalid. #[error("sprint invalid: {0}")] Invalid(String), /// Store I/O or serialization failed. #[error("sprint store failed: {0}")] Store(String), } // --------------------------------------------------------------------------- // Git port support types // --------------------------------------------------------------------------- /// Status of a single path in the working tree. #[derive(Debug, Clone, PartialEq, Eq)] pub struct GitFileStatus { /// Path relative to the repo root. pub path: String, /// Whether the change is staged. pub staged: bool, } /// A commit summary. #[derive(Debug, Clone, PartialEq, Eq)] pub struct GitCommitInfo { /// Commit hash. pub hash: String, /// Commit message summary. pub summary: String, } /// A commit enriched for graph display (DAG edges + refs + author + time). #[derive(Debug, Clone, PartialEq, Eq)] pub struct GraphCommit { /// Full commit hash (hex string). pub hash: String, /// First line of the commit message. pub summary: String, /// Parent commit hashes (0 for root, ≥2 for merges). pub parents: Vec, /// Human-readable ref labels pointing at this commit /// (e.g. `"main"`, `"tag: v1.0"`). pub refs: Vec, /// Author name (empty string when unavailable). pub author: String, /// Author timestamp in Unix seconds. pub timestamp: i64, } // --------------------------------------------------------------------------- // Ports // --------------------------------------------------------------------------- /// Launch/drive an AI CLI according to an [`AgentProfile`], handling context /// injection. CPU-bound and synchronous — kept plain `fn`. pub trait AgentRuntime: Send + Sync { /// Detects whether the profile's command is available. /// /// # Errors /// [`RuntimeError`] on detection failure. fn detect(&self, profile: &AgentProfile) -> Result; /// Builds a [`SpawnSpec`] (command + args + injection plan) for launching /// the agent in `cwd` with the prepared context. /// /// # Errors /// [`RuntimeError`] if the invocation cannot be prepared. fn prepare_invocation( &self, profile: &AgentProfile, ctx: &PreparedContext, cwd: &ProjectPath, session: &SessionPlan, ) -> Result; } /// Une **session programmatique persistante** avec un agent IA (ARCHITECTURE §17.1) : /// une conversation vivante que l'on pilote en mode structuré et dont on lit la /// réponse de façon déterministe. Une instance ⇔ un agent IA (invariant « 1 session /// vivante/agent », porté au niveau *type*). /// /// Hexagonal : ce trait est **domaine** ; les adapters Claude/Codex (infra) ne /// fuient aucun détail de CLI à travers lui. Substituable (Liskov) : Claude et /// Codex offrent les mêmes garanties (flux d'événements → [`ReplyEvent::Final`] /// déterministe), seul le moteur diffère. /// /// Calqué sur [`PtyPort`] (consommé comme trait-objet `Arc`, /// d'où `#[async_trait]` pour rester object-safe — cf. note d'en-tête du module). #[async_trait] pub trait AgentSession: Send + Sync { /// L'id de session IdeA (mappe la cellule/agent, comme un [`PtyHandle::session_id`]). fn id(&self) -> SessionId; /// L'id de conversation **du moteur** (opaque), persisté sur la cellule pour la /// reprise (§15.2). `None` tant que le moteur n'en a pas attribué. Permet à /// `LeafCell.conversation_id` de rester le pivot de reprise, model-agnostic. fn conversation_id(&self) -> Option; /// Transmet `prompt` à la session vivante et retourne le **flux** d'événements /// du tour (deltas → [`ReplyEvent::Final`]). Rendu incrémental (UI chat) ET /// base du rendez-vous synchrone (helper applicatif `send_blocking`). /// /// # Errors /// [`AgentSessionError::Io`]/[`AgentSessionError::Decode`] sur échec de /// communication/décodage. async fn send(&self, prompt: &str) -> Result; /// Comme [`AgentSession::send`], avec un tap optionnel d'événements live produits /// pendant le tour avant que le flux batch final soit rendu. L'implémentation par /// défaut préserve les sessions/fakes existants : aucun tap, puis [`send`](Self::send). /// /// # Errors /// Identiques à [`AgentSession::send`]. async fn send_with_tap( &self, prompt: &str, _tap: std::sync::mpsc::Sender, ) -> Result { self.send(prompt).await } /// Termine proprement la session (tue le process/SDK sous-jacent). Idempotent. /// /// # Errors /// [`AgentSessionError::Io`] si l'arrêt échoue. async fn shutdown(&self) -> Result<(), AgentSessionError>; } /// **Factory** sélectionnée par le profil (ARCHITECTURE §17.1) : crée/reprend une /// [`AgentSession`] pour un agent IA. C'est elle qui sait *quel adapter* instancier /// (Claude/Codex) selon `profile.structured_adapter` (§17.3). Open/Closed : ajouter /// un moteur structuré = ajouter un adapter + une variante de registre, sans /// toucher au cœur. #[async_trait] pub trait AgentSessionFactory: Send + Sync { /// Vrai si cette factory sait piloter `profile` en mode structuré (sert au menu /// de sélection §17.6 : ne proposer que les profils supportés). fn supports(&self, profile: &AgentProfile) -> bool; /// Démarre une session structurée pour `profile` dans `cwd` (run dir isolé /// §14.1), avec le contexte déjà préparé ([`PreparedContext`]) et l'intention de /// session ([`SessionPlan`] : neuf / assign / resume — réutilise §15). /// /// `sandbox` est le plan de sandbox OS **par lancement** (lot LP4-4), déjà /// compilé (pur, domaine) au launch path et porté jusqu'ici comme il l'est pour /// le chemin PTY via [`SpawnSpec::sandbox`]. `None` ⇒ aucun plan ⇒ exécution /// native inchangée (invariant produit : rien posé ⇒ rien projeté). Le plan /// franchit le port en tant que **valeur domaine** ([`crate::sandbox::SandboxPlan`]) ; /// l'enforcer concret reste côté infra (injecté par instance dans la fabrique). /// /// # Errors /// [`AgentSessionError::Start`] si la CLI/SDK est indisponible ou le mode /// structuré ne peut s'initialiser. async fn start( &self, profile: &AgentProfile, ctx: &PreparedContext, cwd: &ProjectPath, session: &SessionPlan, sandbox: Option<&crate::sandbox::SandboxPlan>, ) -> Result, AgentSessionError>; } /// Open and drive pseudo-terminals. #[async_trait] pub trait PtyPort: Send + Sync { /// Spawns a PTY running `spec` at the given `size`. /// /// # Errors /// [`PtyError`] on failure. async fn spawn(&self, spec: SpawnSpec, size: PtySize) -> Result; /// Writes bytes to the PTY. /// /// # Errors /// [`PtyError`] on failure. fn write(&self, handle: &PtyHandle, data: &[u8]) -> Result<(), PtyError>; /// Resizes the PTY. /// /// # Errors /// [`PtyError`] on failure. fn resize(&self, handle: &PtyHandle, size: PtySize) -> Result<(), PtyError>; /// Subscribes to the PTY's byte output stream. /// /// Re-subscribable: each call returns a fresh stream that receives every /// chunk produced **from now on**. Combined with [`scrollback`](Self::scrollback) /// this lets the presentation layer *re-attach* a view to a still-living PTY /// after a navigation/layout change tore the previous view down — without /// re-spawning the process. /// /// # Errors /// [`PtyError`] if the handle is unknown. fn subscribe_output(&self, handle: &PtyHandle) -> Result; /// Returns the recent output retained for the session (a bounded scrollback /// ring buffer, the most recent bytes). Used to repaint a view that /// re-attaches to a live PTY so the terminal isn't blank. /// /// # Errors /// [`PtyError`] if the handle is unknown. fn scrollback(&self, handle: &PtyHandle) -> Result, PtyError>; /// Kills the PTY's process, returning its exit status. /// /// # Errors /// [`PtyError`] on failure. async fn kill(&self, handle: &PtyHandle) -> Result; } /// Run a non-interactive process and capture its output. #[async_trait] pub trait ProcessSpawner: Send + Sync { /// Runs `spec` to completion. /// /// # Errors /// [`ProcessError`] on failure. async fn run(&self, spec: SpawnSpec) -> Result; } /// Location-neutral filesystem access. #[async_trait] pub trait FileSystem: Send + Sync { /// Reads a file. /// /// # Errors /// [`FsError`] on failure. async fn read(&self, path: &RemotePath) -> Result, FsError>; /// Writes a file (creating or truncating). /// /// # Errors /// [`FsError`] on failure. async fn write(&self, path: &RemotePath, data: &[u8]) -> Result<(), FsError>; /// Returns whether the path exists. /// /// # Errors /// [`FsError`] on failure. async fn exists(&self, path: &RemotePath) -> Result; /// Removes a single file. A **missing** file is treated as success (idempotent /// delete), so this is safe to call best-effort. /// /// Defaults to a **no-op `Ok(())`** so existing adapters and in-memory test /// doubles keep compiling unchanged; the real adapter overrides it with an /// actual delete. Callers that rely on the file actually being gone must use an /// adapter that overrides this (the local FS does). /// /// # Errors /// [`FsError`] on a failure other than not-found. async fn remove_file(&self, path: &RemotePath) -> Result<(), FsError> { let _ = path; Ok(()) } /// Creates a directory and all missing parents. /// /// # Errors /// [`FsError`] on failure. async fn create_dir_all(&self, path: &RemotePath) -> Result<(), FsError>; /// Lists the entries of a directory. /// /// # Errors /// [`FsError`] on failure. async fn list(&self, path: &RemotePath) -> Result, FsError>; /// Creates a symbolic link `dst` pointing at `src`. /// /// # Errors /// [`FsError`] on failure. async fn symlink(&self, src: &RemotePath, dst: &RemotePath) -> Result<(), FsError>; } /// Strategy abstracting *where* execution happens (local / SSH / WSL). Acts as a /// factory for the location-appropriate fine-grained ports. #[async_trait] pub trait RemoteHost: Send + Sync { /// The kind of this host. fn kind(&self) -> RemoteKind; /// Establishes the connection (no-op for local). /// /// # Errors /// [`RemoteError`] on failure. async fn connect(&self) -> Result<(), RemoteError>; /// Returns the filesystem port for this host. fn file_system(&self) -> Arc; /// Returns the process spawner for this host. fn process_spawner(&self) -> Arc; /// Returns the PTY port for this host. fn pty(&self) -> Arc; } /// CRUD + versioning for agent templates in the global IDE store. #[async_trait] pub trait TemplateStore: Send + Sync { /// Lists all templates. /// /// # Errors /// [`StoreError`] on failure. async fn list(&self) -> Result, StoreError>; /// Gets a template by id. /// /// # Errors /// [`StoreError::NotFound`] if absent. async fn get(&self, id: crate::ids::TemplateId) -> Result; /// Saves (creates or replaces) a template. /// /// # Errors /// [`StoreError`] on failure. async fn save(&self, template: &AgentTemplate) -> Result<(), StoreError>; /// Deletes a template. /// /// # Errors /// [`StoreError`] on failure. async fn delete(&self, id: crate::ids::TemplateId) -> Result<(), StoreError>; } /// CRUD for [`Skill`]s across both scopes ([`SkillScope::Global`] in the IDE /// store, [`SkillScope::Project`] under `.ideai/skills/`), per ARCHITECTURE /// §14.2 and L12. /// /// The two scopes are **isolated**: a skill saved as `Project` never surfaces in /// a `Global` listing and vice-versa. Each call carries the [`SkillScope`] /// explicitly (or via the [`Skill`] for [`save`](Self::save)) so the adapter /// resolves the right backing location. /// /// `root` identifies the project whose `.ideai/skills/` to use for /// [`SkillScope::Project`]; it is **ignored** for [`SkillScope::Global`] (which /// lives in the machine-global IDE store). Passing the root per call — rather /// than baking it into the adapter — keeps a single store instance correct /// across every open project (mirroring [`AgentContextStore`]). #[async_trait] pub trait SkillStore: Send + Sync { /// Lists all skills in `scope` (for `root`'s project when project-scoped). /// /// # Errors /// [`StoreError`] on failure. async fn list(&self, scope: SkillScope, root: &ProjectPath) -> Result, StoreError>; /// Gets a skill by id within `scope`. /// /// # Errors /// [`StoreError::NotFound`] if absent in that scope. async fn get( &self, scope: SkillScope, root: &ProjectPath, id: crate::ids::SkillId, ) -> Result; /// Saves (creates or replaces by id) a skill in its own [`Skill::scope`]. /// /// # Errors /// [`StoreError`] on failure. async fn save(&self, skill: &Skill, root: &ProjectPath) -> Result<(), StoreError>; /// Deletes a skill by id within `scope`. /// /// # Errors /// [`StoreError::NotFound`] if absent in that scope. async fn delete( &self, scope: SkillScope, root: &ProjectPath, id: crate::ids::SkillId, ) -> Result<(), StoreError>; } /// Persistence for the agent **live-state** snapshot (programme live-state). /// /// Mirrors the other stores ([`SkillStore`], [`MemoryStore`]): a driven port the /// infrastructure implements. The contract is **keyed last-writer-wins**, never /// append — [`upsert`](LiveStateStore::upsert) replaces the row for an agent and /// [`prune`](LiveStateStore::prune) applies the TTL + cardinality bound, matching /// [`LiveState`](crate::live_state::LiveState)'s pure semantics. #[async_trait] pub trait LiveStateStore: Send + Sync { /// Loads the current live-state snapshot. /// /// # Errors /// [`StoreError`] on failure. async fn load(&self) -> Result; /// Inserts or replaces (keyed by `agent_id`) a single live-state row. /// /// # Errors /// [`StoreError`] on failure. async fn upsert(&self, entry: crate::live_state::LiveEntry) -> Result<(), StoreError>; /// Drops rows older than `ttl_ms` relative to `now_ms`, then bounds the set /// to `max_n`, keeping the most recently updated rows. /// /// # Errors /// [`StoreError`] on failure. async fn prune(&self, now_ms: u64, ttl_ms: u64, max_n: usize) -> Result<(), StoreError>; } /// CRUD for project [`Memory`] notes — the `.md` knowledge base under /// `.ideai/memory/` (LOT A, étage 1). Notes are the single source of truth; the /// aggregated `MEMORY.md` index is derived and kept in sync on every write. /// /// `root` identifies the project whose `.ideai/memory/` to use; it is supplied /// **per call** (mirroring [`SkillStore`]) so a single store instance serves /// every open project. #[async_trait] pub trait MemoryStore: Send + Sync { /// Lists all memory notes for `root`'s project. /// /// # Errors /// [`MemoryError`] on failure (e.g. a malformed note's frontmatter). async fn list(&self, root: &ProjectPath) -> Result, MemoryError>; /// Gets a memory note by slug. /// /// # Errors /// [`MemoryError::NotFound`] if absent; [`MemoryError::Frontmatter`] if its /// frontmatter is malformed. async fn get(&self, root: &ProjectPath, slug: &MemorySlug) -> Result; /// Saves (creates or replaces by slug) a note: writes `.md` and upserts /// its line in `MEMORY.md` idempotently. /// /// # Errors /// [`MemoryError`] on failure. async fn save(&self, root: &ProjectPath, memory: &Memory) -> Result<(), MemoryError>; /// Deletes a note by slug, removing its line from `MEMORY.md`. /// /// # Errors /// [`MemoryError::NotFound`] if absent. async fn delete(&self, root: &ProjectPath, slug: &MemorySlug) -> Result<(), MemoryError>; /// Reads the aggregated `MEMORY.md` index as structured entries (empty if the /// index does not exist yet). /// /// # Errors /// [`MemoryError`] on an I/O failure. async fn read_index(&self, root: &ProjectPath) -> Result, MemoryError>; /// Resolves the `[[slug]]` links emanating from `slug`'s note, **ignoring /// broken links** (targets that do not resolve to an existing note). /// /// # Errors /// [`MemoryError::NotFound`] if `slug` itself does not exist. async fn resolve_links( &self, root: &ProjectPath, slug: &MemorySlug, ) -> Result, MemoryError>; } /// A recall request: the query text plus the token budget bounding the result /// (LOT B, étage 1). `text` is typically the agent's current context; the naïve /// adapter ignores it, but a semantic [`MemoryRecall`] (LOT C) ranks against it. #[derive(Debug, Clone, PartialEq, Eq)] pub struct MemoryQuery { /// The recall query (often the agent's current working context). pub text: String, /// Approximate token budget the returned entries must fit within. A budget of /// `0` yields an empty result. pub token_budget: usize, } /// Adaptive recall of the most relevant subset of a project's memory index for a /// query, bounded by a token budget (LOT B, étage 1). /// /// Contract (best-effort, never blocking): /// - an empty or absent memory yields an empty list, **never** an error; /// - a `token_budget` of `0` yields an empty list; /// - the naïve adapter ignores semantic relevance and returns the index entries /// in order, truncated to fit the budget. /// /// **Liskov**: every implementation (`NaiveMemoryRecall`, the future /// `VectorMemoryRecall`) is substitutable — same emptiness/budget guarantees, only /// the relevance strategy differs. #[async_trait] pub trait MemoryRecall: Send + Sync { /// Returns the entries most relevant to `query` for `root`'s project, capped /// at `query.token_budget`. /// /// # Errors /// [`MemoryError`] only on an unexpected I/O failure of the underlying store; /// an empty or missing memory is **not** an error (returns an empty list). async fn recall( &self, root: &ProjectPath, query: &MemoryQuery, ) -> Result, MemoryError>; } /// Produces embedding vectors for texts, driven by a declarative /// [`crate::profile::EmbedderProfile`] (façon §9 — adding an engine is *data*, /// not code: Open/Closed). The étage-2 vector recall (LOT C) composes this port /// to rank memory notes semantically. /// /// Contract: /// - [`embed`](Self::embed) returns **one vector per input text**, in the same /// order, each of length [`dimension`](Self::dimension); /// - it is **best-effort from the caller's standpoint**: any failure is an /// [`EmbedderError`], on which a composing [`MemoryRecall`] degrades to the /// naïve recall — it must never bubble up as a hard recall error; /// - implementations are **substitutable** (Liskov): a deterministic test /// embedder and a real ONNX/HTTP one differ only in vector quality, not in the /// shape of their guarantees. #[async_trait] pub trait Embedder: Send + Sync { /// Stable identifier of this embedder (e.g. `"local-onnx-minilm"`). fn id(&self) -> &str; /// Embeds each text into a `dimension()`-length vector, preserving order. /// /// # Errors /// - [`EmbedderError::Unavailable`] if the engine is not installed/reachable, /// - [`EmbedderError::Unsupported`] if the strategy is not implemented yet, /// - [`EmbedderError::Io`] on an I/O failure. async fn embed(&self, texts: &[String]) -> Result>, EmbedderError>; /// The length of every vector produced by [`embed`](Self::embed). fn dimension(&self) -> usize; } /// Persistence of the known-projects registry and the workspace. #[async_trait] pub trait ProjectStore: Send + Sync { /// Lists all known projects. /// /// # Errors /// [`StoreError`] on failure. async fn list_projects(&self) -> Result, StoreError>; /// Loads a single project. /// /// # Errors /// [`StoreError::NotFound`] if absent. async fn load_project(&self, id: crate::ids::ProjectId) -> Result; /// Saves (creates or replaces) a project. /// /// # Errors /// [`StoreError`] on failure. async fn save_project(&self, project: &Project) -> Result<(), StoreError>; /// Saves the whole workspace (windows/tabs/layouts). /// /// # Errors /// [`StoreError`] on failure. async fn save_workspace(&self, workspace: &crate::layout::Workspace) -> Result<(), StoreError>; /// Loads the persisted workspace. /// /// # Errors /// [`StoreError`] on failure. async fn load_workspace(&self) -> Result; } /// CRUD for the configured [`AgentProfile`]s in the global IDE store /// (`profiles.json`, ARCHITECTURE §9.2). Profiles are the *data* that drives the /// single generic [`AgentRuntime`] adapter (Open/Closed). #[async_trait] pub trait ProfileStore: Send + Sync { /// Lists all configured profiles. /// /// # Errors /// [`StoreError`] on failure. async fn list(&self) -> Result, StoreError>; /// Saves (creates or replaces by id) a profile. /// /// # Errors /// [`StoreError`] on failure. async fn save(&self, profile: &AgentProfile) -> Result<(), StoreError>; /// Deletes a profile by id. /// /// # Errors /// [`StoreError`] on failure. async fn delete(&self, id: crate::ids::ProfileId) -> Result<(), StoreError>; /// Whether the profiles store has been initialised yet — used to detect the /// first run of the IDE (no `profiles.json` ⇒ first run). /// /// # Errors /// [`StoreError`] on failure. async fn is_configured(&self) -> Result; /// Persists an (possibly empty) profiles store, recording that the first run /// is complete even when the user kept no profile. /// /// # Errors /// [`StoreError`] on failure. async fn mark_configured(&self) -> Result<(), StoreError>; } /// CRUD for the declarative [`EmbedderProfile`]s in the global IDE store /// (`embedder.json`, LOT C, §14.5.3). Mirrors [`ProfileStore`] for the embedding /// engines: adding an engine is *data* the user configures, not code. /// /// There is **no** `is_configured`/`mark_configured`: the default embedder posture /// is `none` **by absence of the file** (an empty/missing `embedder.json` ⇒ recall /// stays the dependency-free naïve étage 1). A configured embedder takes effect at /// the **next IDE start** (the composition root freezes the recall for the session). #[async_trait] pub trait EmbedderProfileStore: Send + Sync { /// Lists all configured embedder profiles (empty when none configured). /// /// # Errors /// [`StoreError`] on failure. async fn list(&self) -> Result, StoreError>; /// Saves (creates or replaces by id) an embedder profile. /// /// # Errors /// [`StoreError`] on failure. async fn save(&self, profile: &EmbedderProfile) -> Result<(), StoreError>; /// Deletes an embedder profile by id. /// /// # Errors /// [`StoreError::NotFound`] if no profile carries that id. async fn delete(&self, id: &str) -> Result<(), StoreError>; } /// Best-effort snapshot of the embedding *environment*: which local engines are /// already installed/cached on this machine. Drives the "configure an embedder?" /// UI (C2/C3) so it can detect an existing engine before suggesting a download. #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct EmbedderEnvReport { /// Whether an Ollama-style local embedding server was detected (best-effort; /// always `false` in a build without the HTTP capability). pub ollama_detected: bool, /// Ids of the recommended ONNX models already present in the local cache. pub onnx_cached_models: Vec, } /// Probes the local embedding environment (installed/cached engines). **Best-effort /// by contract**: [`inspect`](Self::inspect) never errors — an unreachable host or /// an unreadable cache simply yields a report with the corresponding fields unset. #[async_trait] pub trait EmbedderEnvInspector: Send + Sync { /// Returns a best-effort snapshot of the local embedding environment. async fn inspect(&self) -> EmbedderEnvReport; } /// The persisted user response to the "configure an embedder?" suggestion /// (ARCHITECTURE §14.5.5, LOT C3), stored per project under /// `.ideai/memory/.embedder-prompt.json`. Absence of the file ⇒ never answered. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum EmbedderPromptDismissal { /// "Plus tard" — re-proposable on a future session (not this one). Later, /// "Ne plus demander" — never publish the suggestion again (persistent). Never, } /// Persistence of the per-project embedder-suggestion state /// (`.ideai/memory/.embedder-prompt.json`, LOT C3). A fine-grained port (Interface /// Segregation) so the suggestion use cases depend on this alone, not on a wider /// store. **Best-effort reads**: a missing/malformed file is *not* an error — it /// reads as "never answered" ([`None`]). #[async_trait] pub trait EmbedderPromptStore: Send + Sync { /// Reads the recorded dismissal for `root`'s project, or `None` when the user /// has never answered (no file / unreadable / malformed). /// /// # Errors /// [`StoreError`] only on an unexpected I/O failure the adapter chooses to /// surface; a plain missing file degrades to `Ok(None)`. async fn read(&self, root: &ProjectPath) -> Result, StoreError>; /// Records the user's dismissal choice for `root`'s project. /// /// # Errors /// [`StoreError`] on a persistence failure. async fn write( &self, root: &ProjectPath, dismissal: EmbedderPromptDismissal, ) -> Result<(), StoreError>; } /// Reads/writes agent `.md` contexts and the project manifest, within a project. #[async_trait] pub trait AgentContextStore: Send + Sync { /// Reads an agent's context. /// /// # Errors /// [`StoreError`] on failure. async fn read_context( &self, project: &Project, agent: &AgentId, ) -> Result; /// Writes an agent's context. /// /// # Errors /// [`StoreError`] on failure. async fn write_context( &self, project: &Project, agent: &AgentId, md: &MarkdownDoc, ) -> Result<(), StoreError>; /// Loads the project manifest. /// /// # Errors /// [`StoreError`] on failure. async fn load_manifest(&self, project: &Project) -> Result; /// Saves the project manifest. /// /// # Errors /// [`StoreError`] on failure. async fn save_manifest( &self, project: &Project, manifest: &AgentManifest, ) -> Result<(), StoreError>; } /// Reads/writes a project's `.ideai/permissions.json`. #[async_trait] pub trait PermissionStore: Send + Sync { /// Loads the project's permission document. Missing file returns the default /// empty document. /// /// # Errors /// [`StoreError`] on I/O or deserialisation failure. async fn load_permissions(&self, project: &Project) -> Result; /// Saves the project's permission document. /// /// # Errors /// [`StoreError`] on I/O or serialisation failure. async fn save_permissions( &self, project: &Project, permissions: &ProjectPermissions, ) -> Result<(), StoreError>; } /// Persistence port for first-class background tasks. #[async_trait] pub trait BackgroundTaskStore: Send + Sync { /// Creates a new task. /// /// # Errors /// [`BackgroundTaskPortError`] if the task already exists or cannot be stored. async fn create(&self, task: &BackgroundTask) -> Result<(), BackgroundTaskPortError>; /// Gets a task by id. /// /// # Errors /// [`BackgroundTaskPortError`] on store failure. async fn get(&self, id: TaskId) -> Result, BackgroundTaskPortError>; /// Saves a task. /// /// # Errors /// [`BackgroundTaskPortError`] on store failure. async fn save(&self, task: &BackgroundTask) -> Result<(), BackgroundTaskPortError>; /// Lists non-terminal tasks owned by an agent. /// /// # Errors /// [`BackgroundTaskPortError`] on store failure. async fn list_open_for_agent( &self, agent_id: AgentId, ) -> Result, BackgroundTaskPortError>; /// Lists terminal tasks whose completion has not been delivered yet. /// /// # Errors /// [`BackgroundTaskPortError`] on store failure. async fn list_undelivered_completions( &self, ) -> Result, BackgroundTaskPortError>; /// Marks a completion as delivered. /// /// # Errors /// [`BackgroundTaskPortError`] on store failure. async fn mark_completion_delivered( &self, task_id: TaskId, ) -> Result<(), BackgroundTaskPortError>; } /// Execution port for first-class background tasks. #[async_trait] pub trait BackgroundTaskRunner: Send + Sync { /// Spawns a background task. /// /// # Errors /// [`BackgroundTaskPortError`] when the task cannot be spawned. async fn spawn( &self, spec: BackgroundTaskSpec, ) -> Result; /// Cancels a running task. /// /// # Errors /// [`BackgroundTaskPortError`] when cancellation fails. async fn cancel(&self, task_id: TaskId) -> Result<(), BackgroundTaskPortError>; /// Subscribes to terminal completions. fn subscribe_completions(&self) -> BackgroundCompletionStream; } /// Wakes an agent to process one queued system/inbox item. #[async_trait] pub trait AgentWakePort: Send + Sync { /// Wakes `agent` in `project` for `reason`, if the agent is idle. /// /// Implementations must drain at most one inbox item and must not start a /// concurrent turn when the agent is already busy. /// /// # Errors /// [`WakeError`] when the session/store operation fails or the queued item is /// inconsistent with the requested wake. async fn wake_agent( &self, project: &Project, agent: AgentId, reason: WakeReason, ) -> Result<(), WakeError>; } /// Allocates monotonic per-project issue numbers. #[async_trait] pub trait IssueNumberAllocator: Send + Sync { /// Allocates the next number for `root`. /// /// Implementations must never reuse an allocated number, even if a later /// issue creation step fails. /// /// # Errors /// [`IssueStoreError`] on persistence/lock failure. async fn allocate_next(&self, root: &ProjectPath) -> Result; } /// Persistence port for project-scoped issues. #[async_trait] pub trait IssueStore: Send + Sync { /// Creates a new issue. /// /// # Errors /// [`IssueStoreError`] when the issue already exists or cannot be stored. async fn create(&self, root: &ProjectPath, issue: &Issue) -> Result<(), IssueStoreError>; /// Reads an issue by `#N` reference. /// /// # Errors /// [`IssueStoreError::NotFound`] when absent. async fn get_by_ref( &self, root: &ProjectPath, issue_ref: IssueRef, ) -> Result; /// Lists issue index entries matching `filter`. /// /// # Errors /// [`IssueStoreError`] on persistence failure. async fn list( &self, root: &ProjectPath, filter: IssueListFilter, ) -> Result, IssueStoreError>; /// Replaces an issue after checking its expected version. /// /// # Errors /// [`IssueStoreError::VersionConflict`] on optimistic-concurrency conflict. async fn update( &self, root: &ProjectPath, issue: &Issue, expected_version: IssueVersion, ) -> Result<(), IssueStoreError>; /// Deletes an issue by `#N` reference. /// /// # Errors /// [`IssueStoreError::NotFound`] when absent. async fn delete(&self, root: &ProjectPath, issue_ref: IssueRef) -> Result<(), IssueStoreError>; /// Reads only the issue carnet projection. /// /// # Errors /// [`IssueStoreError::NotFound`] when absent. async fn read_carnet( &self, root: &ProjectPath, issue_ref: IssueRef, ) -> Result; /// Replaces an issue carnet and increments the issue version. /// /// # Errors /// [`IssueStoreError::VersionConflict`] on optimistic-concurrency conflict. async fn write_carnet( &self, root: &ProjectPath, issue_ref: IssueRef, carnet: MarkdownDoc, actor: crate::issue::IssueActor, now_ms: u64, expected_version: IssueVersion, ) -> Result; } /// Persistence port for project-scoped sprints. #[async_trait] pub trait SprintStore: Send + Sync { /// Creates a new sprint. /// /// # Errors /// [`SprintStoreError`] when the sprint already exists or cannot be stored. async fn create(&self, root: &ProjectPath, sprint: &Sprint) -> Result<(), SprintStoreError>; /// Reads a sprint by stable id. /// /// # Errors /// [`SprintStoreError::NotFound`] when absent. async fn get( &self, root: &ProjectPath, sprint_id: SprintId, ) -> Result; /// Lists sprint index entries. /// /// # Errors /// [`SprintStoreError`] on persistence failure. async fn list(&self, root: &ProjectPath) -> Result, SprintStoreError>; /// Replaces a sprint after checking its expected version. /// /// # Errors /// [`SprintStoreError::VersionConflict`] on optimistic-concurrency conflict. async fn update( &self, root: &ProjectPath, sprint: &Sprint, expected_version: SprintVersion, ) -> Result<(), SprintStoreError>; /// Deletes a sprint by id. /// /// # Errors /// [`SprintStoreError::NotFound`] when absent. async fn delete(&self, root: &ProjectPath, sprint_id: SprintId) -> Result<(), SprintStoreError>; } /// Git operations for a project. Named `GitPort` to avoid clashing with the /// [`crate::git::GitRepository`] *entity* (state image). #[async_trait] pub trait GitPort: Send + Sync { /// Initialises a repository at the root. /// /// # Errors /// [`GitError`] on failure. async fn init(&self, root: &ProjectPath) -> Result<(), GitError>; /// Returns the status of changed paths. /// /// # Errors /// [`GitError`] on failure. async fn status(&self, root: &ProjectPath) -> Result, GitError>; /// Stages a path. /// /// # Errors /// [`GitError`] on failure. async fn stage(&self, root: &ProjectPath, path: &str) -> Result<(), GitError>; /// Unstages a path. /// /// # Errors /// [`GitError`] on failure. async fn unstage(&self, root: &ProjectPath, path: &str) -> Result<(), GitError>; /// Creates a commit with the given message. /// /// # Errors /// [`GitError`] on failure. async fn commit(&self, root: &ProjectPath, message: &str) -> Result; /// Lists branches. /// /// # Errors /// [`GitError`] on failure. async fn branches(&self, root: &ProjectPath) -> Result, GitError>; /// Returns the current branch. /// /// # Errors /// [`GitError`] on failure. async fn current_branch(&self, root: &ProjectPath) -> Result, GitError>; /// Checks out a branch. /// /// # Errors /// [`GitError`] on failure. async fn checkout(&self, root: &ProjectPath, branch: &str) -> Result<(), GitError>; /// Returns the recent commit log. /// /// # Errors /// [`GitError`] on failure. async fn log(&self, root: &ProjectPath, limit: usize) -> Result, GitError>; /// Returns the commit graph (all local branches, topological + time sort). /// /// # Errors /// [`GitError`] on failure. async fn log_graph( &self, root: &ProjectPath, limit: usize, ) -> Result, GitError>; /// Pulls from the default remote. /// /// # Errors /// [`GitError`] on failure. async fn pull(&self, root: &ProjectPath) -> Result<(), GitError>; /// Pushes to the default remote. /// /// # Errors /// [`GitError`] on failure. async fn push(&self, root: &ProjectPath) -> Result<(), GitError>; } /// **Optional** capability to read enriched details from a CLI's conversation /// transcript (CONTEXT §T6). /// /// This port is optional *by construction*: it backs a best-effort resume popup /// and is never wired as a hard dependency of the launch/resume flow. A caller /// (the future `InspectConversation` use case, §T7) holds a /// `Vec>` and routes a profile to the first inspector /// whose [`supports`](Self::supports) returns `true`; if none matches, or the /// call errors, the resume proceeds with no enriched details. /// /// All Claude/Codex/Gemini transcript shapes stay **inside the adapter**: no /// CLI-specific type ever crosses this boundary — only [`ConversationDetails`]. #[async_trait] pub trait SessionInspector: Send + Sync { /// Returns whether this inspector knows how to read transcripts for the /// given profile (e.g. by recognising its context-injection convention). fn supports(&self, profile: &AgentProfile) -> bool; /// Reads best-effort [`ConversationDetails`] for `conversation_id` whose /// agent runs in `cwd`. /// /// # Errors /// [`InspectError::NotFound`] when no transcript exists for the conversation; /// [`InspectError::Read`] on an I/O / decoding failure. Malformed *lines* /// inside an otherwise-readable transcript are skipped, not surfaced. async fn details( &self, profile: &AgentProfile, conversation_id: &str, cwd: &ProjectPath, ) -> Result; } /// Callback fired **once per detected turn end** by a [`TurnWatcher`]. /// /// Invoked from the watcher's own polling task (never while holding a lock), so the /// composition root can safely route it to /// [`crate::input::InputMediator::turn_ended`] for the given agent. pub type OnTurnEnd = Arc; /// Opaque handle to a live [`TurnWatcher::watch`]. **Dropping it stops the watch** /// (the adapter's polling task must observe the drop and cease firing, idempotently). pub trait TurnWatchHandle: Send + Sync {} /// Observes an agent's **end-of-turn** signal from its on-disk transcript, replacing /// the dead PTY prompt-ready sniff as the turn-end authority (rendez-vous no-reply /// backstop). Model-specific: an adapter watches the CLI's transcript shape (e.g. the /// Claude `turn_duration` record) and fires [`OnTurnEnd`] each time a turn completes. /// /// Pure port (no async, no I/O in the trait): the adapter owns its polling task. The /// composition root arms one watch per **live agent session** (keyed on `cwd`, the /// agent's isolated run dir) and drops the handle on close/exit. pub trait TurnWatcher: Send + Sync { /// Whether this watcher knows how to read turn ends for the given profile /// (mirrors [`SessionInspector::supports`] — e.g. recognising `CLAUDE.md`). fn supports(&self, profile: &AgentProfile) -> bool; /// Arms a watch over `agent`'s transcript under `cwd` (its run dir). The adapter /// captures a **baseline** of turn ends already present at arm time and fires /// `on_turn_end(agent)` only on increments **above** that baseline (so a /// pre-existing transcript — relaunch / background wake — never triggers a phantom /// turn end). `conversation_id` is an optional diagnostic label only — it is **not** /// the file key (the transcript stem is the engine session id, unknown at cold /// start). Returns a [`TurnWatchHandle`]; dropping it stops the watch. fn watch( &self, agent: AgentId, conversation_id: Option, cwd: ProjectPath, on_turn_end: OnTurnEnd, ) -> Box; } /// Publish/subscribe domain events. Synchronous, in-process. pub trait EventBus: Send + Sync { /// Publishes an event. fn publish(&self, event: DomainEvent); /// Subscribes to the event stream. fn subscribe(&self) -> EventStream; } /// Provides the current time (epoch milliseconds), abstracted for determinism. pub trait Clock: Send + Sync { /// Returns "now" as epoch milliseconds. fn now_millis(&self) -> i64; } /// Generates fresh UUIDs, abstracted for determinism in tests. pub trait IdGenerator: Send + Sync { /// Returns a fresh UUID. fn new_uuid(&self) -> uuid::Uuid; } /// Minuterie **one-shot annulable** (ARCHITECTURE §21.4) — le **seul** port neuf de /// la feature « limites de session ». Là où [`Clock`] dit *quelle heure il est*, ce /// port *réveille* à une échéance absolue donnée. /// /// # Interface Segregation (§21.8-I) /// Volontairement minimal — `arm` / `cancel`, rien d'autre — et **distinct** de /// [`Clock`] : un consommateur qui n'a besoin que de « réveille-moi à T » ne dépend /// pas d'un store ni d'une horloge. /// /// # Synchrone (pas d'`async_trait`) /// Comme [`Clock`], [`IdGenerator`] et [`EventBus`], ce port est **non bloquant** : /// `arm` ne fait qu'enregistrer une minuterie (l'attente réelle se passe en tâche de /// fond dans l'adapter) et `cancel` ne fait qu'annuler — aucun `await` au point /// d'appel. On le garde donc en `fn` simple, sans payer le boxing d'`async_trait`. /// /// # Remise de la tâche échue — dispatch par DONNÉE (§14.3, §21.4) /// Le port **n'exécute jamais** la reprise (aucune dépendance vers l'application). /// À l'échéance, l'adapter **pousse la [`ScheduledTask`] (une valeur)** dans un canal /// fourni à sa construction, que l'application **draine** et exécute — exactement le /// patron du watcher d'orchestrateur, qui dispatche une requête-donnée vers /// l'`OrchestratorService`. Aucune closure ne franchit la frontière domaine. /// /// # État en mémoire (§21.1-3) /// Aucune persistance : un réveil armé ne survit pas à un redémarrage d'IdeA (le /// chemin `ListResumableAgents` existant prend alors le relais). /// /// # Substituabilité (Liskov) /// Tout adapter (le `TokioScheduler` réel comme un fake de test qui tire à la /// demande) respecte le même contrat : `arm` rend un id annulable ; `cancel` renvoie /// `true` ssi il a effectivement désarmé un réveil non encore tiré. pub trait Scheduler: Send + Sync { /// Arme une minuterie one-shot à `deadline_ms` (**époche-millisecondes absolues**, /// cohérent avec [`Clock::now_millis`] et `ResumePlan::Scheduled.fire_at_ms`). À /// l'échéance, l'adapter remet `task` au drain applicatif (cf. doc du trait). /// Renvoie un [`ScheduleId`] annulable. /// /// `deadline_ms` **≤ now** ⇒ déclenchement **au plus tôt** (immédiat). Le clamp /// anti-passé est déjà assuré en amont par `plan_resume` (domaine) ; ce contrat ne /// fait que garantir qu'une échéance passée ne « se perd » pas. fn arm(&self, deadline_ms: i64, task: ScheduledTask) -> ScheduleId; /// Annule un réveil armé **non encore tiré**. Idempotent et **sans erreur** : /// renvoie `true` s'il a effectivement été désarmé, `false` s'il était inconnu ou /// déjà tiré. C'est ce qui sous-tend la « reprise auto **annulable** » (§21.1-4). fn cancel(&self, id: ScheduleId) -> bool; }