Files
IdeA/crates/domain/src/ports.rs
Blomios 8570adb8e0 fix(ticket-assistant): édition de ticket via les tools MCP idea_ticket_* (#27)
L'assistant IA d'édition de ticket éditait les fichiers du ticket en direct,
hors de tout contrôle. Il passe désormais par les tools MCP idea_ticket_* :
préparation d'un environnement structuré dédié et policy d'enforcement scopée
au ticket courant, de sorte que l'assistant ne peut agir que sur son ticket
via la surface MCP plutôt que sur le système de fichiers.

Couvert par de nouveaux tests QA (mcp_server, assistant_context_store).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 09:10:23 +02:00

2092 lines
77 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 serde_json::Value;
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, LocalModelServerId, 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::model_server::{LocalModelServerConfig, ModelServerEndpoint, ModelServerStatus};
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<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,
}
/// Prepared environment for a structured assistant session.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StructuredSessionEnvironment {
/// Isolated cwd where the structured CLI must run.
pub cwd: ProjectPath,
/// Environment variables to pass to the structured session.
pub env: Vec<(String, 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<PreparedContext, AssistantContextError>;
}
/// Prepares the isolated run environment for a structured ticket assistant.
#[async_trait]
pub trait StructuredSessionEnvironmentPreparer: Send + Sync {
/// Materialises context and MCP configuration for a ticket assistant session.
///
/// # Errors
/// [`RuntimeError`] when the structured environment cannot be prepared.
async fn prepare_ticket_assistant(
&self,
project: &Project,
issue_ref: IssueRef,
profile: &AgentProfile,
prepared: &PreparedContext,
requester: &str,
) -> Result<StructuredSessionEnvironment, RuntimeError>;
}
/// 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<AgentToolPolicy>;
/// 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<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>,
}
/// Opaque handle for a managed local process.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct ManagedProcessHandle {
/// Stable local process identity as assigned by the adapter.
pub id: String,
}
/// Observed status of a managed local process.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProcessStatus {
/// Process is still running.
Running,
/// Process has exited.
Exited {
/// Exit code, when available.
code: Option<i32>,
},
/// Handle is unknown to the adapter.
Unknown,
}
/// 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>;
/// A boxed stream of background task completions.
pub type BackgroundCompletionStream = Box<dyn Iterator<Item = BackgroundTaskCompletion> + 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<SpawnSpec>,
/// Optional absolute deadline, epoch milliseconds.
pub deadline_ms: Option<u64>,
}
/// 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<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>;
/// Description pure d'un outil exposé à un modèle OpenAI-compatible.
#[derive(Debug, Clone, PartialEq)]
pub struct ToolSpec {
/// Nom stable de l'outil (ex. `idea_ask_agent`).
pub name: String,
/// Description destinée au modèle.
pub description: String,
/// Schéma JSON d'entrée de l'outil.
pub input_schema: Value,
}
/// Erreurs du port d'invocation d'outils agentiques.
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum ToolInvocationError {
/// Outil inconnu ou non exposé par l'orchestrateur.
#[error("tool not found: {0}")]
NotFound(String),
/// Arguments invalides ou non conformes au schéma attendu.
#[error("tool invalid arguments: {0}")]
InvalidArguments(String),
/// Refus par gating produit/permissions.
#[error("tool rejected: {0}")]
Rejected(String),
/// Échec d'exécution de l'outil.
#[error("tool execution failed: {0}")]
Execution(String),
}
/// Port pur d'invocation des outils `idea_*` pour les modèles qui n'ont pas de
/// client MCP natif. L'impl concrète vit hors domaine.
#[async_trait]
pub trait ToolInvoker: Send + Sync {
/// Liste des outils exposés au modèle.
fn tools(&self) -> Vec<ToolSpec>;
/// Appelle un outil avec ses arguments JSON bruts.
///
/// # Errors
/// [`ToolInvocationError`] si l'outil est refusé, introuvable ou échoue.
async fn call(&self, name: &str, args_json: &str) -> Result<String, ToolInvocationError>;
}
/// 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 local model-server ports.
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum ModelServerError {
/// The referenced configuration does not exist.
#[error("not configured")]
NotConfigured,
/// Server or configuration is invalid.
#[error("invalid: {0}")]
Invalid(String),
/// Filesystem permission denied.
#[error("permission denied: {0}")]
PermissionDenied(String),
/// Required path is not accessible.
#[error("path not accessible: {0}")]
PathNotAccessible(String),
/// The configured port is already in use by another managed server.
#[error("port occupied: {0}")]
PortOccupied(u16),
/// The server is still referenced by another configuration object.
#[error("model server in use: {0}")]
InUse(String),
/// Readiness probe failed unexpectedly.
#[error("probe failed: {0}")]
Probe(String),
/// Process operation failed.
#[error("process failed: {0}")]
Process(String),
/// Store operation failed.
#[error("store failed: {0}")]
Store(String),
/// Readiness timed out.
#[error("readiness timed out")]
Timeout,
}
/// 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<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.
#[async_trait]
pub trait AgentRuntime: Send + Sync {
/// Detects whether the profile's command is available.
///
/// # Errors
/// [`RuntimeError`] on detection failure.
async 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>;
/// 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<ReplyEvent>,
) -> Result<ReplyStream, AgentSessionError> {
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,
env: &[(String, String)],
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>;
}
/// Probe readiness of an OpenAI-compatible model server.
#[async_trait]
pub trait ModelServerProbe: Send + Sync {
/// Probes `endpoint` and returns the observed status.
///
/// # Errors
/// [`ModelServerError`] on unexpected probe failure. An unreachable server
/// should normally return [`ModelServerStatus::Unreachable`].
async fn probe(
&self,
endpoint: &ModelServerEndpoint,
) -> Result<ModelServerStatus, ModelServerError>;
}
/// Manages local long-lived child processes.
#[async_trait]
pub trait ManagedProcess: Send + Sync {
/// Spawns a process from an argv-structured spec.
///
/// # Errors
/// [`ModelServerError`] on spawn failure.
async fn spawn(&self, spec: SpawnSpec) -> Result<ManagedProcessHandle, ModelServerError>;
/// Kills a process.
///
/// # Errors
/// [`ModelServerError`] on kill failure.
async fn kill(&self, handle: &ManagedProcessHandle) -> Result<(), ModelServerError>;
/// Returns the status of a process.
///
/// # Errors
/// [`ModelServerError`] on status failure.
async fn status(
&self,
handle: &ManagedProcessHandle,
) -> Result<ProcessStatus, ModelServerError>;
}
/// Builds the argv-structured spawn spec for a local model server.
pub trait ModelServerRuntime: Send + Sync {
/// Builds the pure argv contract for previewing or spawning a local server.
///
/// # Errors
/// [`ModelServerError`] if the config cannot be launched.
fn build_argv(
&self,
config: &LocalModelServerConfig,
) -> Result<ModelServerArgv, ModelServerError>;
/// Builds a spawn spec from a validated local-server config.
///
/// # Errors
/// [`ModelServerError`] if the config cannot be launched.
fn build_spawn_spec(
&self,
config: &LocalModelServerConfig,
) -> Result<SpawnSpec, ModelServerError>;
}
/// Pure argv contract for a local model server invocation.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ModelServerArgv {
/// Executable command.
pub command: String,
/// Arguments without shell parsing.
pub args: Vec<String>,
}
/// Persists local model-server configuration in the global IdeA store.
#[async_trait]
pub trait ModelServerRegistry: Send + Sync {
/// Returns one server config by id.
///
/// # Errors
/// [`ModelServerError`] on store failure.
async fn get(
&self,
id: &LocalModelServerId,
) -> Result<Option<LocalModelServerConfig>, ModelServerError>;
/// Lists all server configs.
///
/// # Errors
/// [`ModelServerError`] on store failure.
async fn list(&self) -> Result<Vec<LocalModelServerConfig>, ModelServerError>;
/// Saves one server config.
///
/// # Errors
/// [`ModelServerError`] on store failure.
async fn save(&self, config: LocalModelServerConfig) -> Result<(), ModelServerError>;
/// Deletes one server config.
///
/// # Errors
/// [`ModelServerError`] on store failure.
async fn delete(&self, id: LocalModelServerId) -> Result<(), ModelServerError>;
}
/// 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>;
}
/// Machine-local persistence of open OS window state.
#[async_trait]
pub trait WindowStateStore: Send + Sync {
/// Saves the latest open-window snapshot.
///
/// # Errors
/// [`StoreError`] on persistence failure.
async fn save_window_state(
&self,
snapshot: &crate::layout::WindowStateSnapshot,
) -> Result<(), StoreError>;
/// Loads the latest open-window snapshot.
///
/// # Errors
/// [`StoreError`] on unexpected I/O or deserialisation failure. A missing
/// snapshot is represented as an empty default snapshot.
async fn load_window_state(&self) -> Result<crate::layout::WindowStateSnapshot, 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>;
}
/// 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<Option<BackgroundTask>, 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<Vec<BackgroundTask>, BackgroundTaskPortError>;
/// Lists terminal tasks whose completion has not been delivered yet.
///
/// # Errors
/// [`BackgroundTaskPortError`] on store failure.
async fn list_undelivered_completions(
&self,
) -> Result<Vec<BackgroundTask>, 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<BackgroundTaskHandle, BackgroundTaskPortError>;
/// 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<IssueNumber, IssueStoreError>;
}
/// 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<Issue, IssueStoreError>;
/// Lists issue index entries matching `filter`.
///
/// # Errors
/// [`IssueStoreError`] on persistence failure.
async fn list(
&self,
root: &ProjectPath,
filter: IssueListFilter,
) -> Result<Vec<IssueIndexEntry>, 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<IssueCarnet, IssueStoreError>;
/// 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<IssueCarnet, IssueStoreError>;
}
/// 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<Sprint, SprintStoreError>;
/// Lists sprint index entries.
///
/// # Errors
/// [`SprintStoreError`] on persistence failure.
async fn list(&self, root: &ProjectPath) -> Result<Vec<SprintIndexEntry>, 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<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>;
}
/// 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<dyn Fn(AgentId) + Send + Sync>;
/// 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<String>,
cwd: ProjectPath,
on_turn_end: OnTurnEnd,
) -> Box<dyn TurnWatchHandle>;
}
/// 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;
}