Files
IdeA/crates/domain/src/ports.rs
Blomios 9815af01b1 feat(domain): live-state agent — modèle + port (LS1)
Domaine pur du live-state des agents : `LiveState`/`LiveEntry`/`WorkStatus`.
- Fusion en keyed last-writer-wins : une entrée par clé, la plus récente
  écrase l'ancienne (ordonnancement déterministe).
- Invariants de bornes anti-dump : bornes douces (troncature) + rejet dur
  au-delà des limites, pour empêcher un agent de noyer le live-state.
- `prune` pour borner la rétention.
- Port `LiveStateStore` (lecture/écriture), sans dépendance d'infra.

9 nouveaux tests live_state (cargo test -p domain : 208 passed / 0 failed).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 19:03:19 +02:00

1375 lines
53 KiB
Rust

//! 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<dyn FileSystem>`,
//! 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::events::DomainEvent;
use crate::ids::{AgentId, NodeId, ScheduleId, SessionId};
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::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<String>,
},
/// 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<String>,
/// Working directory.
pub cwd: ProjectPath,
/// Extra environment variables.
pub env: Vec<(String, String)>,
/// How the context is injected, if any.
pub context_plan: Option<ContextInjectionPlan>,
/// 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<crate::sandbox::SandboxPlan>,
}
/// 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,
}
/// 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<String>,
/// A best-effort cumulative token count for the conversation. `None` when no
/// usage information is available.
pub token_count: Option<u64>,
}
/// 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<i32>,
}
/// 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<u8>,
/// Captured stderr.
pub stderr: Vec<u8>,
}
/// 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<String>) -> 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<dyn Iterator<Item = Vec<u8>> + Send>;
/// A boxed stream of domain events, returned by [`EventBus::subscribe`].
pub type EventStream = Box<dyn Iterator<Item = DomainEvent> + Send>;
/// 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,
},
/// **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<i64>,
},
/// **É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<dyn Iterator<Item = ReplyEvent> + 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<String>,
},
}
// ---------------------------------------------------------------------------
// 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),
}
// ---------------------------------------------------------------------------
// 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<String>,
/// Human-readable ref labels pointing at this commit
/// (e.g. `"main"`, `"tag: v1.0"`).
pub refs: Vec<String>,
/// 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<bool, RuntimeError>;
/// 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<SpawnSpec, RuntimeError>;
}
/// 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<dyn AgentSession>`,
/// 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<String>;
/// 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<ReplyStream, AgentSessionError>;
/// 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<Arc<dyn AgentSession>, 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<PtyHandle, PtyError>;
/// 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<OutputStream, PtyError>;
/// 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<Vec<u8>, PtyError>;
/// Kills the PTY's process, returning its exit status.
///
/// # Errors
/// [`PtyError`] on failure.
async fn kill(&self, handle: &PtyHandle) -> Result<ExitStatus, PtyError>;
}
/// 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<Output, ProcessError>;
}
/// 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<Vec<u8>, 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<bool, FsError>;
/// 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<Vec<DirEntry>, 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<dyn FileSystem>;
/// Returns the process spawner for this host.
fn process_spawner(&self) -> Arc<dyn ProcessSpawner>;
/// Returns the PTY port for this host.
fn pty(&self) -> Arc<dyn PtyPort>;
}
/// 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<Vec<AgentTemplate>, StoreError>;
/// Gets a template by id.
///
/// # Errors
/// [`StoreError::NotFound`] if absent.
async fn get(&self, id: crate::ids::TemplateId) -> Result<AgentTemplate, StoreError>;
/// 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<Vec<Skill>, 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<Skill, StoreError>;
/// 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<crate::live_state::LiveState, StoreError>;
/// 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<Vec<Memory>, 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<Memory, MemoryError>;
/// Saves (creates or replaces by slug) a note: writes `<slug>.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<Vec<MemoryIndexEntry>, 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<Vec<MemoryLink>, 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<Vec<MemoryIndexEntry>, 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<Vec<Vec<f32>>, 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<Vec<Project>, StoreError>;
/// Loads a single project.
///
/// # Errors
/// [`StoreError::NotFound`] if absent.
async fn load_project(&self, id: crate::ids::ProjectId) -> Result<Project, StoreError>;
/// 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<crate::layout::Workspace, StoreError>;
}
/// 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<Vec<AgentProfile>, 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<bool, StoreError>;
/// 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<Vec<EmbedderProfile>, 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<String>,
}
/// 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<Option<EmbedderPromptDismissal>, 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<MarkdownDoc, StoreError>;
/// 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<AgentManifest, StoreError>;
/// 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<ProjectPermissions, StoreError>;
/// 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>;
}
/// 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<Vec<GitFileStatus>, 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<GitCommitInfo, GitError>;
/// Lists branches.
///
/// # Errors
/// [`GitError`] on failure.
async fn branches(&self, root: &ProjectPath) -> Result<Vec<String>, GitError>;
/// Returns the current branch.
///
/// # Errors
/// [`GitError`] on failure.
async fn current_branch(&self, root: &ProjectPath) -> Result<Option<String>, 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<Vec<GitCommitInfo>, 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<Vec<GraphCommit>, 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<Arc<dyn SessionInspector>>` 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<ConversationDetails, InspectError>;
}
/// 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;
}