//! Domain events published on the [`crate::ports::EventBus`] and relayed to the //! presentation layer (ARCHITECTURE §3.2). use crate::ids::{AgentId, IssueId, ProfileId, ProjectId, SessionId, SkillId, TemplateId}; use crate::issue::{IssueLinkKind, IssuePriority, IssueRef, IssueStatus, IssueVersion}; use crate::mailbox::TicketId; use crate::memory::MemorySlug; use crate::template::TemplateVersion; /// Which entry door a processed orchestration request arrived through. /// /// IdeA exposes the *same* [`crate::OrchestratorService::dispatch`] behind two /// substitutable driving adapters (ARCHITECTURE §14.3 + cadrage `orchestration-v3`): /// the `.ideai/requests` filesystem watcher and the MCP server. This tag — set by /// the adapter that received the request, never inferred in the application — lets /// the presentation layer surface *how* a delegation came in. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum OrchestrationSource { /// A JSON request file dropped under `.ideai/requests/`. File, /// A `tools/call` on the MCP server. Mcp, } /// Events emitted by the domain/application as state changes occur. /// /// Deliberately *not* `Serialize`/`Deserialize`: events are an in-process /// concern relayed to IPC by an infrastructure adapter, which owns the wire /// format. `PtyOutput` in particular is usually short-circuited to a Tauri /// channel rather than serialised here. #[derive(Debug, Clone, PartialEq, Eq)] pub enum DomainEvent { /// A project was created. ProjectCreated { /// The new project. project_id: ProjectId, }, /// An agent was launched in a terminal. AgentLaunched { /// The agent. agent_id: AgentId, /// The session it runs in. session_id: SessionId, }, /// A target agent produced a synchronous reply to an inter-agent `ask` /// (ARCHITECTURE §17.4): the requester sent a task via `agent.message`, IdeA /// drove the target's structured session to its turn `Final`, and this is the /// observability beacon for that completed rendezvous. Carries the target id and /// the reply size (a lightweight metric); the full body is returned to the /// requester out-of-band, not in the event payload (the bus stays I/O-free). AgentReplied { /// The agent that produced the reply. agent_id: AgentId, /// Number of bytes in the reply content (preview metric, not the payload). reply_len: usize, }, /// An agent's process exited. AgentExited { /// The agent. agent_id: AgentId, /// Exit code. code: i32, }, /// An agent's **busy/idle** state changed (cadrage C4 §4.2): it went `Busy` on /// the enqueue that started a turn, or `Idle` on `mark_idle` (prompt-ready or an /// explicit signal). Discrete, low-frequency beacon relayed to the front so the /// mediated-input view can dim "Envoyer" while a turn is in flight (it never /// blocks the enqueue — the FIFO keeps accepting; the flag is purely advisory). AgentBusyChanged { /// The agent whose state changed. agent_id: AgentId, /// `true` when a turn is in flight, `false` when the agent is idle. busy: bool, }, /// An agent's **liveness** (alive/stalled) changed (lot 2, chantier /// readiness/heartbeat). Emitted **once per transition** by the stall detector: /// `Alive→Stalled` when no proof of liveness arrived for longer than the profile's /// `stall_after_ms`, and `Stalled→Alive` when a late battement (delta / tool /// activity / heartbeat) revives it or the agent returns to `Idle`. Discrete, /// low-frequency beacon (no spam) relayed to the front so the mediated-input view /// can badge a frozen agent. Purely advisory — the FIFO and the turn keep running. AgentLivenessChanged { /// The agent whose liveness changed. agent_id: AgentId, /// The new liveness state. liveness: crate::input::AgentLiveness, }, /// An agent's runtime profile was changed (hot-swap of the AI engine). AgentProfileChanged { /// The agent. agent_id: AgentId, /// The new runtime profile it now uses. profile_id: ProfileId, }, /// A template was updated (content changed, version bumped). TemplateUpdated { /// The template. template_id: TemplateId, /// New version. version: TemplateVersion, }, /// A synchronized agent is behind its template. AgentDriftDetected { /// The drifting agent. agent_id: AgentId, /// Version the agent is currently at. from: TemplateVersion, /// Version available from the template. to: TemplateVersion, }, /// A synchronized agent received its template update. AgentSynced { /// The agent. agent_id: AgentId, /// Version it was brought up to. to: TemplateVersion, }, /// A skill was assigned to (or unassigned from) an agent. SkillAssigned { /// The agent whose skill set changed. agent_id: AgentId, /// The skill involved. skill_id: SkillId, /// `true` if assigned, `false` if unassigned. assigned: bool, }, /// A tab's layout changed. LayoutChanged { /// The project whose layout changed. project_id: ProjectId, }, /// A remote host connection was established. RemoteConnected { /// The project on that remote. project_id: ProjectId, }, /// Git state for a project changed. GitStateChanged { /// The project. project_id: ProjectId, }, /// An orchestrator request (dropped under `.ideai/requests/`) was processed /// by IdeA on behalf of a requester agent (ARCHITECTURE §14.3). Relayed so the /// frontend can surface orchestration activity; the resulting cell/tab opens /// off the [`AgentLaunched`](Self::AgentLaunched) event for `spawn_agent`. OrchestratorRequestProcessed { /// Id of the requesting (orchestrator) agent — the request subdirectory. requester_id: String, /// The action that was processed (`spawn_agent`, `stop_agent`, …). action: String, /// Whether IdeA handled it successfully. ok: bool, /// Which entry door the request arrived through (file watcher vs MCP), /// tagged by the receiving adapter. source: OrchestrationSource, }, /// A memory note was created or updated (`.md` written, index upserted). MemorySaved { /// The saved note's slug. slug: MemorySlug, }, /// A memory note was deleted. MemoryDeleted { /// The deleted note's slug. slug: MemorySlug, }, /// The aggregated `MEMORY.md` index was rebuilt for a project. MemoryIndexRebuilt { /// The project whose index was rebuilt. project_id: ProjectId, }, /// An issue was created. IssueCreated { /// Stable issue id. issue_id: IssueId, /// Human-friendly issue reference. issue_ref: IssueRef, }, /// An issue was updated. IssueUpdated { /// Human-friendly issue reference. issue_ref: IssueRef, /// New optimistic version. version: IssueVersion, }, /// An issue status changed. IssueStatusChanged { /// Human-friendly issue reference. issue_ref: IssueRef, /// New status. status: IssueStatus, /// New optimistic version. version: IssueVersion, }, /// An issue priority changed. IssuePriorityChanged { /// Human-friendly issue reference. issue_ref: IssueRef, /// New priority. priority: IssuePriority, /// New optimistic version. version: IssueVersion, }, /// An issue carnet was updated. IssueCarnetUpdated { /// Human-friendly issue reference. issue_ref: IssueRef, /// New optimistic version. version: IssueVersion, }, /// Two issues were linked. IssueLinked { /// Source issue. issue_ref: IssueRef, /// Target issue. target: IssueRef, /// Link kind. kind: IssueLinkKind, /// New optimistic version. version: IssueVersion, }, /// Two issues were unlinked. IssueUnlinked { /// Source issue. issue_ref: IssueRef, /// Target issue. target: IssueRef, /// Link kind. kind: IssueLinkKind, /// New optimistic version. version: IssueVersion, }, /// An agent was assigned to an issue. IssueAgentAssigned { /// Human-friendly issue reference. issue_ref: IssueRef, /// Assigned agent. agent_id: AgentId, /// New optimistic version. version: IssueVersion, }, /// An agent was unassigned from an issue. IssueAgentUnassigned { /// Human-friendly issue reference. issue_ref: IssueRef, /// Unassigned agent. agent_id: AgentId, /// New optimistic version. version: IssueVersion, }, /// A project's memory grew past the recall budget while no embedder is /// configured (strategy `none`): the first moment a semantic embedder would /// help (ARCHITECTURE §14.5.5, LOT C3). Published **at most once per session per /// project** (and never again once the user chose "ne plus demander"); the /// frontend surfaces a one-time, dismissible suggestion. Carries the detected /// local environment and the compiled-in capabilities so the UI never proposes /// a strategy this binary cannot run. EmbedderSuggested { /// The project whose memory crossed the budget. project_id: ProjectId, /// Whether an Ollama-style local embedding server was detected (best-effort). ollama_detected: bool, /// Ids of the recommended ONNX models already present in the local cache. onnx_cached: Vec, /// Whether the HTTP capability (`localServer`/`api`) is compiled in. vector_http_enabled: bool, /// Whether the in-process ONNX capability (`localOnnx`) is compiled in. vector_onnx_enabled: bool, }, /// A delegation is ready to be injected into the agent's **native terminal** /// (ARCHITECTURE §20.2/§20.3). Published when a turn *starts* (Idle→Busy); the /// backend stays the authority of the FIFO/busy state and **no longer writes the /// turn into the PTY** — the frontend cell runs the write-portal handshake and /// writes `text` + `submit_sequence` through the single PTY writer (the front). /// Discrete, low-frequency (one per delegation). Relayed to the front as /// `delegationReady` like every other [`DomainEvent`]. DelegationReady { /// The target agent whose terminal will receive the delegation. agent_id: AgentId, /// The mailbox ticket correlating this turn (acked back via /// `delegation_delivered`); the requester's `ask` is woken by `idea_reply`. ticket: TicketId, /// The task text to inject (written without a trailing `\n` by the portal). text: String, /// Target profile's submit sequence (the cell applies it after the text). /// `None` ⇒ the front applies its default (`"\r"`); never hard-coded in the /// domain. submit_sequence: Option, /// Target profile's submit delay in ms. `None` ⇒ front default (~60 ms). submit_delay_ms: Option, }, /// Un agent vient d'entrer en **limite de session/débit** (ARCHITECTURE §21). /// Publié quand le service de limite enregistre une nouvelle `SessionLimit` (niveau /// 1 structuré ou niveau 2 motif). Balise discrète, basse fréquence, relayée au /// front pour afficher le badge « limité jusqu'à HH:MM ». Model-agnostique : ne /// porte que le fait neutre « limité, reset à T (peut-être) ». AgentRateLimited { /// L'agent entré en limite. agent_id: AgentId, /// Instant de reset en **époche-millisecondes**. `None` ⇒ heure inconnue /// (pas de reprise auto, filet humain). resets_at_ms: Option, }, /// Une **reprise automatique** a été armée pour un agent limité (ARCHITECTURE §21). /// Publié après que le service a calculé le plan ([`crate::session_limit::plan_resume`]) /// et armé le `Scheduler`. Relayé au front pour afficher le compte à rebours + le /// bouton « Annuler la reprise » (fenêtre annulable). AgentResumeScheduled { /// L'agent dont la reprise est programmée. agent_id: AgentId, /// Échéance du réveil en **époche-millisecondes**. fire_at_ms: i64, }, /// La **reprise automatique** d'un agent a été **annulée** (ARCHITECTURE §21) : /// l'utilisateur a cliqué « Annuler la reprise » dans la fenêtre annulable. Relayé /// au front pour retirer le compte à rebours. AgentResumeCancelled { /// L'agent dont la reprise a été annulée. agent_id: AgentId, }, /// Un agent a effectivement été **relancé** après une limite (ARCHITECTURE §21) : /// le réveil a tiré (ou reprise immédiate), l'agent a redémarré via /// [`crate::ports::SessionPlan::Resume`] avec un prompt de reprise court. Relayé au /// front pour effacer l'état « limité ». AgentResumed { /// L'agent relancé. agent_id: AgentId, }, /// **Filet humain (niveau 3)** : une limite de session est **suspectée** sans /// qu'aucune heure de reset fiable ne soit connue (ARCHITECTURE §21.1 niveau 3) — /// typiquement un agent passé `Stalled` (lot 2) sans `SessionLimit` connue. IdeA ne /// reprend **jamais** à l'aveugle : ce signal demande au front de **solliciter /// l'utilisateur** (« limite détectée mais heure inconnue — reprendre à ? »). AgentRateLimitSuspected { /// L'agent dont la limite est suspectée. agent_id: AgentId, /// Instant de reset en **époche-millisecondes** si une estimation existe, /// sinon `None` (l'utilisateur fournira l'heure). resets_at_ms: Option, }, /// The project's **orchestrator designation** changed (cadrage « orchestrateur /// du projet », T1). Emitted when an agent is designated (radio selection) or the /// designation is cleared back to the default (e.g. the designated agent was /// deleted — lazy succession to the oldest agent). Relayed to the front so the UI /// can move the radio / refresh the orchestrator badge. Model-agnostic: carries /// only the neutral fact « who orchestrates now ». OrchestratorChanged { /// The project whose designation changed. project_id: ProjectId, /// The newly designated orchestrator agent, or `None` for the default /// (the oldest agent orchestrates). orchestrator: Option, }, /// Raw PTY output (usually routed to a dedicated channel, not this bus). PtyOutput { /// The session. session_id: SessionId, /// Output bytes. bytes: Vec, }, } #[cfg(test)] mod tests { use super::*; fn agent(n: u128) -> AgentId { AgentId::from_uuid(uuid::Uuid::from_u128(n)) } // -- §21 : constructibilité + égalité PartialEq des 5 variantes -------------- #[test] fn agent_rate_limited_constructs_and_compares() { let ev = DomainEvent::AgentRateLimited { agent_id: agent(1), resets_at_ms: Some(1_700_000_000_000), }; assert_eq!( ev, DomainEvent::AgentRateLimited { agent_id: agent(1), resets_at_ms: Some(1_700_000_000_000), } ); // Une heure différente ⇒ inégaux. assert_ne!( ev, DomainEvent::AgentRateLimited { agent_id: agent(1), resets_at_ms: None, } ); } #[test] fn agent_resume_scheduled_constructs_and_compares() { let ev = DomainEvent::AgentResumeScheduled { agent_id: agent(2), fire_at_ms: 1_700_000_000_000, }; assert_eq!( ev, DomainEvent::AgentResumeScheduled { agent_id: agent(2), fire_at_ms: 1_700_000_000_000, } ); assert_ne!( ev, DomainEvent::AgentResumeScheduled { agent_id: agent(2), fire_at_ms: 0, } ); } #[test] fn agent_resume_cancelled_constructs_and_compares() { let ev = DomainEvent::AgentResumeCancelled { agent_id: agent(3) }; assert_eq!(ev, DomainEvent::AgentResumeCancelled { agent_id: agent(3) }); assert_ne!(ev, DomainEvent::AgentResumeCancelled { agent_id: agent(4) }); } #[test] fn agent_resumed_constructs_and_compares() { let ev = DomainEvent::AgentResumed { agent_id: agent(5) }; assert_eq!(ev, DomainEvent::AgentResumed { agent_id: agent(5) }); assert_ne!(ev, DomainEvent::AgentResumed { agent_id: agent(6) }); } #[test] fn agent_rate_limit_suspected_constructs_and_compares() { let ev = DomainEvent::AgentRateLimitSuspected { agent_id: agent(7), resets_at_ms: None, }; assert_eq!( ev, DomainEvent::AgentRateLimitSuspected { agent_id: agent(7), resets_at_ms: None, } ); assert_ne!( ev, DomainEvent::AgentRateLimitSuspected { agent_id: agent(7), resets_at_ms: Some(42), } ); } #[test] fn orchestrator_changed_constructs_and_compares() { let project = ProjectId::from_uuid(uuid::Uuid::from_u128(100)); let ev = DomainEvent::OrchestratorChanged { project_id: project, orchestrator: Some(agent(1)), }; assert_eq!( ev, DomainEvent::OrchestratorChanged { project_id: project, orchestrator: Some(agent(1)), } ); // Clearing to the default (None) is a distinct event. assert_ne!( ev, DomainEvent::OrchestratorChanged { project_id: project, orchestrator: None, } ); } #[test] fn distinct_session_limit_variants_are_not_equal() { // Les variantes ne se confondent pas entre elles malgré des champs proches. assert_ne!( DomainEvent::AgentResumeCancelled { agent_id: agent(8) }, DomainEvent::AgentResumed { agent_id: agent(8) } ); } }