Files
IdeaSDK/crates/domain/src/input.rs
Blomios 27597eb64e feat(permissions): voie projection CLI (LP0→LP3) + checkpoint Codex/input
Jalon vert regroupant deux chantiers entrelacés dans le working tree,
indissociables au niveau fichier mais tous deux verts (cargo test
--workspace + tests frontend permissions au vert).

Permissions — voie « projection CLI » (advisory), complète :
- LP0 domaine pur : modèle PermissionSet/EffectivePermissions, resolve
  deny-wins + postures Allow<Ask<Deny (crates/domain/src/permission.rs).
- LP1 store : FsPermissionStore (.ideai/permissions.json).
- LP2 use cases : Get/Update project, Update agent override, Resolve.
- LP3 projecteurs Claude/Codex (settings.local.json / config.toml),
  câblage launch-path + PermissionProjectorRegistry, nettoyage des
  fichiers Replace orphelins au swap de profil (LP3-4), composition root
  + commandes Tauri, UI PermissionsPanel (projet + override agent).
- ports.rs : PermissionStore + FileSystem::remove_file (cleanup au swap).

Reste ouvert (hors scope, marqué dans le code) : LP4 enforcement OS
airtight (Landlock fichiers) + résumé de permissions injecté.

Inclut aussi le chantier Codex/input/sessions structurées en cours
(McpConfigStrategy, StructuredAdapter, gestion d'input) partageant les
mêmes fichiers (lifecycle.rs, commands.rs, dto.rs, state.rs).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 20:39:18 +02:00

323 lines
15 KiB
Rust

//! Mediated agent input (cadrage « entrée médiée par IdeA », lot C1/C2).
//!
//! Every input to an agent — **human** keystrokes *and* **inter-agent** delegations
//! — converges on **one FIFO per agent**, with `enqueue` (Envoyer) and `preempt`
//! (Interrompre) kept distinct. This module owns the pure value objects
//! ([`InputSource`], [`AgentBusyState`]) and the [`InputMediator`] port.
//!
//! The mediator **absorbs** [`crate::mailbox::AgentMailbox`]: the existing mailbox
//! (FIFO + one-shot reply) is reused as the *correlation engine* inside the infra
//! adapter (`MediatedInbox`), rather than spawning a second concurrent queue. The
//! delegation mailbox is thus just **one input source among two**.
//!
//! Pure (no I/O): the FIFO, locks and busy bookkeeping live in the infra adapter.
use serde::{Deserialize, Serialize};
use crate::ids::AgentId;
use crate::mailbox::{PendingReply, Ticket, TicketId};
use crate::ports::PtyHandle;
/// Where a queued input came from.
///
/// Replaces the free-form `requester: String` as the **source of truth** (the
/// string remains a derived display label). Lets IdeA propagate the requester's
/// identity and feed the wait-for graph (cycle detection).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "kind")]
pub enum InputSource {
/// The human operator (a `SubmitHumanInput` use case).
Human,
/// Another agent delegating via `idea_ask_agent`.
#[serde(rename_all = "camelCase")]
Agent {
/// The delegating agent.
agent_id: AgentId,
},
}
impl InputSource {
/// Convenience constructor for an agent source.
#[must_use]
pub const fn agent(agent_id: AgentId) -> Self {
Self::Agent { agent_id }
}
/// Returns the [`AgentId`] when this source is an agent.
#[must_use]
pub const fn as_agent(&self) -> Option<AgentId> {
match self {
Self::Agent { agent_id } => Some(*agent_id),
Self::Human => None,
}
}
/// Whether the source is the human operator.
#[must_use]
pub const fn is_human(&self) -> bool {
matches!(self, Self::Human)
}
}
/// Whether an agent is currently processing a task.
///
/// Derived state, published to the front (`AgentBusyChanged`). An agent goes `Busy`
/// on the enqueue that **starts a turn**, and returns `Idle` on prompt-ready OR an
/// explicit signal (cf. cadrage §6). In doubt it stays `Busy`, but the FIFO keeps
/// accepting enqueues (forward, never reject the sender).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "state")]
pub enum AgentBusyState {
/// No turn in flight; the next enqueued ticket can start.
Idle,
/// A turn is in flight.
#[serde(rename_all = "camelCase")]
Busy {
/// The ticket whose turn is running.
ticket: TicketId,
/// When the turn started (epoch millis), for UI age display.
since_ms: u64,
},
}
/// Whether an agent currently shows **signs of life** during a turn (lot 2,
/// chantier readiness/heartbeat — détection « Stalled »).
///
/// Orthogonal to [`AgentBusyState`]: an agent can be `Busy` **and** `Alive` (it is
/// working, producing deltas/heartbeats) or `Busy` **and** `Stalled` (no proof of
/// liveness for longer than its profile's `stall_after_ms`). Only meaningful while
/// `Busy`; an `Idle` agent is considered `Alive` (its turn ended cleanly).
///
/// Published to the front (`AgentLivenessChanged`) **once per transition** so the UI
/// can badge a frozen agent without event spam. Derived from the per-agent
/// `last_seen_ms` heartbeat, never from parsing the model output.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "liveness")]
pub enum AgentLiveness {
/// The agent is producing proof of liveness (deltas / tool activity /
/// heartbeats) within its `stall_after_ms` window — or is idle.
Alive,
/// No proof of liveness for longer than the profile's `stall_after_ms`: the
/// agent is presumed frozen. Advisory only — the FIFO and the turn keep running
/// (a late heartbeat flips it back to [`Self::Alive`]).
Stalled,
}
impl AgentBusyState {
/// Whether a turn is currently in flight.
#[must_use]
pub const fn is_busy(&self) -> bool {
matches!(self, Self::Busy { .. })
}
/// The in-flight ticket, if any.
#[must_use]
pub const fn ticket(&self) -> Option<TicketId> {
match self {
Self::Busy { ticket, .. } => Some(*ticket),
Self::Idle => None,
}
}
}
/// Per-agent submit configuration (ARCHITECTURE §20.3), resolved from the target
/// agent's profile and carried to the [`InputMediator`] at bind time so it can be
/// echoed on a [`crate::events::DomainEvent::DelegationReady`].
///
/// Both fields stay `Option`: the **default** (`"\r"`, ~60 ms) is applied at the
/// point of use (the frontend write-portal), never hard-coded in the domain — the
/// domain only transports the declared values.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct SubmitConfig {
/// The profile's `submit_sequence` (e.g. `"\r"`). `None` ⇒ front default.
pub sequence: Option<String>,
/// The profile's `submit_delay_ms`. `None` ⇒ front default (~60 ms).
pub delay_ms: Option<u32>,
}
impl SubmitConfig {
/// Builds a submit config from the two optional profile fields.
#[must_use]
pub const fn new(sequence: Option<String>, delay_ms: Option<u32>) -> Self {
Self { sequence, delay_ms }
}
}
/// The single convergence point of **all** of an agent's input (one FIFO/agent),
/// with `enqueue` (Envoyer) and `preempt` (Interrompre) kept **distinct**, plus the
/// busy state.
///
/// Object-safe (held as `Arc<dyn InputMediator>`). The infra adapter `MediatedInbox`
/// composes the existing [`crate::mailbox::AgentMailbox`] (correlation by ticket) +
/// turn locks + busy bookkeeping — it does **not** create a second queue.
pub trait InputMediator: Send + Sync {
/// Envoyer = enqueue: appends `ticket` to `agent`'s FIFO and returns the
/// [`PendingReply`] to await (the mailbox reply type, reused).
///
/// **Delivery** (ARCHITECTURE §20.2/§20.3): the mediator **no longer writes the
/// turn into the PTY** (the `\n` band-aid is gone — it never submitted in raw mode
/// and produced a "double chat"). Instead, on the enqueue that **starts a turn**
/// (Idle→Busy), the adapter publishes a [`crate::events::DomainEvent::DelegationReady`]
/// carrying the task text + ticket + the target's [`SubmitConfig`]; the frontend
/// cell (sole owner of the terminal) runs the write-portal handshake and writes the
/// text + submit sequence through the single PTY writer. The mediator stays the
/// authority of the FIFO/busy state and correlation only.
fn enqueue(&self, agent: AgentId, ticket: Ticket) -> PendingReply;
/// Registers (or refreshes) the live input [`PtyHandle`] of `agent` so a later
/// [`InputMediator::enqueue`] delivers the turn through it (cadrage C3 §5.2).
///
/// Keyed by **agent** (one live input stream per agent — «1 agent = 1 employee»);
/// the orchestrator calls this once it has resolved/launched the agent's live
/// session for the target conversation. Default: no-op (a mediator that does not
/// own the delivery write).
fn bind_handle(&self, _agent: AgentId, _handle: PtyHandle) {}
/// Like [`InputMediator::bind_handle`], but also arms **prompt-ready detection**
/// (cadrage §6, lot C5): `prompt_ready_pattern` is the agent profile's optional
/// literal marker (`AgentProfile::prompt_ready_pattern`). When `Some`, the mediator
/// watches the bound handle's output stream and calls [`InputMediator::mark_idle`]
/// the first time the marker appears (one of the two OR signals; the other is an
/// explicit `idea_reply`). When `None`, no pattern detection is armed — the agent
/// only returns `Idle` on the explicit signal or the per-turn timeout (fallback
/// «en cas de doute → reste Busy mais la file accepte»; never a false `Idle`).
///
/// It also records the target's [`SubmitConfig`] (ARCHITECTURE §20.3) so the
/// adapter can echo `submit_sequence`/`submit_delay_ms` on the
/// [`crate::events::DomainEvent::DelegationReady`] published when a turn starts.
/// The bind is the natural carrier: both the prompt pattern and the submit config
/// are per-agent profile data the orchestrator resolves at the same time, so they
/// travel together (no extra ticket field, no second resolve).
///
/// Default: delegates to [`InputMediator::bind_handle`], ignoring the pattern and
/// submit config (a mediator that does not observe the output stream). The infra
/// adapter overrides it to arm the watcher and stash the submit config.
fn bind_handle_with_prompt(
&self,
agent: AgentId,
handle: PtyHandle,
_prompt_ready_pattern: Option<String>,
_submit: SubmitConfig,
) {
self.bind_handle(agent, handle);
}
/// **Deprecated since ARCHITECTURE §20**: the mediator never writes the turn into
/// the PTY anymore — the **frontend** always delivers (via the write-portal). Kept
/// for source compatibility; it now always returns `false`. Callers should stop
/// branching on it (the orchestrator no longer falls back to its own PTY write).
#[must_use]
fn delivers_turn(&self, _agent: AgentId) -> bool {
false
}
/// Déclare qu'`agent` vient d'être **lancé à froid** : la livraison de son tout
/// premier tour doit être *gatée* sur le prompt-ready (la `DelegationReady` est
/// différée jusqu'à l'apparition du prompt du CLI), pour éviter d'écrire la tâche
/// avant que le CLI ait fini de booter (premier tour perdu sinon).
///
/// À n'appeler **que** lorsqu'un prompt-ready watcher sera effectivement armé (le
/// profil porte un `prompt_ready_pattern` non vide). Sans watcher pour le libérer,
/// gater le premier tour le bloquerait indéfiniment ; dans ce cas l'orchestrateur ne
/// doit **pas** appeler `mark_starting` et l'`enqueue` livre la tâche immédiatement
/// (chemin chaud, fallback sûr, zéro régression).
///
/// Default: no-op (a mediator that does not gate cold starts).
fn mark_starting(&self, _agent: AgentId) {}
/// Signal de **readiness de démarrage** : le pont MCP de `agent` vient de se
/// connecter (son CLI est up et a chargé les outils `idea_*`). Si un premier tour
/// a été différé par [`InputMediator::mark_starting`] (démarrage à froid), c'est le
/// moment de le livrer ⇒ draine le `DelegationReady` retenu. **Contrairement à
/// `prompt_ready`, aucun repli `mark_idle`** : c'est un signal de DÉMARRAGE, pas de
/// fin de tour. Idempotent : un second appel (ou après que `prompt_ready` ait déjà
/// drainé) ne trouve rien et est un no-op. En OR avec le prompt-ready : le premier
/// arrivé draine, l'autre est no-op.
///
/// Default: no-op (médiateur qui ne gate pas les démarrages à froid).
fn release_cold_start(&self, _agent: AgentId) {}
/// Déclare si une **cellule terminal du frontend** est montée pour `agent`
/// (`true` au montage du write-portal, `false` au démontage). C'est le frontend
/// qui possède l'écriture physique du PTY *quand une cellule existe* (cas d'un
/// agent épinglé dans le layout) ; un agent **délégué en arrière-plan n'a aucune
/// cellule**, donc personne ne consomme `DelegationReady` côté UI. Le médiateur
/// utilise cet état pour décider, à la livraison d'un tour, entre **publier
/// l'événement** (cellule présente ⇒ le front écrit) et **écrire lui-même** la
/// tâche dans le PTY (agent headless ⇒ sinon le tour est perdu).
///
/// Default: no-op (médiateur sans notion de cellule front — tout passe par
/// l'événement, comportement historique).
fn set_front_attached(&self, _agent: AgentId, _attached: bool) {}
/// Interrompre = preempt: signals the running turn to stop (Échap/stop). This
/// is **not** an enqueue and correlates **no** ticket.
fn preempt(&self, agent: AgentId);
/// Marks `agent` free (prompt-ready or explicit signal) so its FIFO advances.
fn mark_idle(&self, agent: AgentId);
/// Records a **proof of liveness** (« battement ») for `agent` — called on every
/// non-terminal turn event (text delta, tool activity, [`crate::ports::ReplyEvent::Heartbeat`])
/// by the drain loop. Refreshes the per-agent `last_seen` timestamp so the stall
/// detector ([`crate::events::DomainEvent::AgentLivenessChanged`], lot 2) knows the
/// agent is still working; a battement arriving after a `Stalled` transition flips it
/// back to [`AgentLiveness::Alive`].
///
/// Default: no-op (a mediator that does not track liveness). The infra adapter
/// `MediatedInbox` overrides it to refresh `last_seen` and publish an
/// `AgentLivenessChanged{Stalled→Alive}` recovery on the first late battement.
fn mark_alive(&self, _agent: AgentId) {}
/// Declares the agent's **stall threshold** (its profile's
/// [`crate::profile::LivenessStrategy::stall_after_ms`]) so the stall detector knows
/// how long without a battement means "frozen" (lot 2). The orchestrator resolves it
/// from the target's profile and calls this **before** the enqueue that starts a
/// turn; the adapter stashes it and arms a fresh liveness window when the turn
/// begins. `None` ⇒ no stall detection for this agent (legacy / no `liveness` block —
/// zero regression).
///
/// Default: no-op (a mediator that does not track liveness).
fn set_stall_threshold(&self, _agent: AgentId, _stall_after_ms: Option<u32>) {}
/// The current [`AgentBusyState`] of `agent`.
fn busy_state(&self, agent: AgentId) -> AgentBusyState;
}
#[cfg(test)]
mod tests {
use super::*;
fn agent(n: u128) -> AgentId {
AgentId::from_uuid(uuid::Uuid::from_u128(n))
}
fn ticket(n: u128) -> TicketId {
TicketId::from_uuid(uuid::Uuid::from_u128(n))
}
#[test]
fn input_source_human_vs_agent() {
assert!(InputSource::Human.is_human());
assert_eq!(InputSource::Human.as_agent(), None);
let s = InputSource::agent(agent(1));
assert!(!s.is_human());
assert_eq!(s.as_agent(), Some(agent(1)));
}
#[test]
fn busy_state_transitions_and_accessors() {
let idle = AgentBusyState::Idle;
assert!(!idle.is_busy());
assert_eq!(idle.ticket(), None);
let busy = AgentBusyState::Busy {
ticket: ticket(7),
since_ms: 1000,
};
assert!(busy.is_busy());
assert_eq!(busy.ticket(), Some(ticket(7)));
assert_ne!(idle, busy);
}
}