Files
IdeA/crates/domain/src/ports.rs
Blomios 5417bd756b feat(tickets): suppression d'un ticket (backend + frontend)
Ajoute la suppression complète d'un ticket (#6).

Backend Rust :
- port IssueStore::delete (NotFound si absent) + event IssueDeleted{freed_sprint}
- FsIssueStore::delete : supprime .ideai/tickets/<N>/ et l'index sous lock
- use case DeleteIssue + adaptations des tests sprint/ticket_assistant au port
- commande Tauri ticket_delete + câblage events/state/lib

Frontend :
- gateway delete (ports, adapter ticket + mock, domain)
- useTicketDetail : retrait de la liste et fermeture du détail via event issueDeleted
- intégration TicketDetail + tests

Tests verts : application/issue_usecases (6), infrastructure/issue_store (7),
app-tauri --lib (56), frontend vitest (503), npm run build (exit 0).

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

1842 lines
69 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::agent_tool_policy::AgentToolPolicy;
use crate::background_task::{
BackgroundTask, BackgroundTaskKind, BackgroundTaskResult, BackgroundTaskWakePolicy,
};
use crate::events::DomainEvent;
use crate::ids::{AgentId, NodeId, ProjectId, ScheduleId, SessionId, SprintId, TaskId};
use crate::issue::{
Issue, IssueCarnet, IssueIndexEntry, IssueListFilter, IssueNumber, IssueRef, IssueVersion,
};
use crate::markdown::MarkdownDoc;
use crate::memory::{Memory, MemoryIndexEntry, MemoryLink, MemorySlug};
use crate::permission::ProjectPermissions;
use crate::profile::{AgentProfile, EmbedderProfile};
use crate::project::{Project, ProjectPath};
use crate::remote::RemoteKind;
use crate::skill::{Skill, SkillScope};
use crate::sprint::{Sprint, SprintIndexEntry, SprintVersion};
use crate::template::AgentTemplate;
use crate::terminal::PtySize;
// ---------------------------------------------------------------------------
// Support value types shared across ports
// ---------------------------------------------------------------------------
/// How the `.md` context should be delivered to a spawned process, resolved
/// from a profile's [`crate::profile::ContextInjection`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ContextInjectionPlan {
/// Materialise the context at a (relative) file path before launch.
File {
/// Relative target path inside the cwd.
target: String,
},
/// Append the given already-rendered arguments to the command line.
Args {
/// Extra arguments carrying the context path.
args: Vec<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,
}
/// 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>;
}
/// 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>,
}
/// 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>;
/// 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),
}
/// 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. 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>;
/// 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,
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>;
}
/// 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;
}