Stretch B2/F2 de #54, par-dessus le MVP déjà mergé (B1/F1). Backend : le port de téléchargement HF publie une progression débouncée (bytes reçus / total, pourcentage) via le stream de statut du serveur modèle, avec gestion du total inconnu (pas de faux %), du cache hit, de l'annulation et du timeout. Frontend : l'overlay plein-cellule de préparation du serveur affiche la progression réelle (barre, %, octets, source) en mappant le fil de statut, avec la règle « pas de faux % » quand le total est inconnu. Tests : application + infrastructure (téléchargement débouncé, cancel, timeout, cache hit, total inconnu) et vitest (overlay + formatage pur). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2158 lines
78 KiB
Rust
2158 lines
78 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::atomic::{AtomicBool, Ordering};
|
|
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::{
|
|
HfModelRef, LocalModelServerConfig, ModelPath, 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),
|
|
/// Operation was cancelled by the caller.
|
|
#[error("cancelled")]
|
|
Cancelled,
|
|
/// 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>;
|
|
}
|
|
|
|
/// Progress observed while resolving/downloading a model artifact.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct ModelArtifactProgress {
|
|
/// Downloaded bytes when known.
|
|
pub downloaded_bytes: Option<u64>,
|
|
/// Total bytes when known.
|
|
pub total_bytes: Option<u64>,
|
|
/// Source being resolved, usually the Hugging Face repo reference.
|
|
pub source: Option<String>,
|
|
}
|
|
|
|
/// Resolved local model artifact.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct ModelArtifactResolution {
|
|
/// Absolute local path to the resolved `.gguf` file.
|
|
pub path: ModelPath,
|
|
/// `true` when the artifact was already available in cache.
|
|
pub cache_hit: bool,
|
|
}
|
|
|
|
/// Cooperative cancellation token for model artifact resolution.
|
|
#[derive(Debug, Clone, Default)]
|
|
pub struct ModelArtifactCancel {
|
|
cancelled: Arc<AtomicBool>,
|
|
}
|
|
|
|
impl ModelArtifactCancel {
|
|
/// Creates a non-cancelled token.
|
|
#[must_use]
|
|
pub fn new() -> Self {
|
|
Self::default()
|
|
}
|
|
|
|
/// Requests cancellation.
|
|
pub fn cancel(&self) {
|
|
self.cancelled.store(true, Ordering::SeqCst);
|
|
}
|
|
|
|
/// Returns whether cancellation was requested.
|
|
#[must_use]
|
|
pub fn is_cancelled(&self) -> bool {
|
|
self.cancelled.load(Ordering::SeqCst)
|
|
}
|
|
}
|
|
|
|
/// Resolves or downloads a model artifact before starting a model server.
|
|
#[async_trait]
|
|
pub trait ModelArtifactDownloader: Send + Sync {
|
|
/// Resolves a Hugging Face model to a local artifact path.
|
|
///
|
|
/// # Errors
|
|
/// [`ModelServerError`] when resolution/download fails or is cancelled.
|
|
async fn resolve_hf_model(
|
|
&self,
|
|
repo: &HfModelRef,
|
|
progress: Arc<dyn Fn(ModelArtifactProgress) + Send + Sync>,
|
|
cancel: ModelArtifactCancel,
|
|
) -> Result<ModelArtifactResolution, 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;
|
|
}
|