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

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

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

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

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

720 lines
25 KiB
Rust

//! Domain events published on the [`crate::ports::EventBus`] and relayed to the
//! presentation layer (ARCHITECTURE §3.2).
use crate::conversation::ConversationParty;
use crate::ids::{
AgentId, IssueId, ProfileId, ProjectId, SessionId, SkillId, SprintId, TaskId, TemplateId,
};
use crate::issue::{IssueLinkKind, IssuePriority, IssueRef, IssueStatus, IssueVersion};
use crate::mailbox::TicketId;
use crate::memory::MemorySlug;
use crate::sprint::{SprintOrder, SprintVersion};
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 first-class background task started running.
BackgroundTaskStarted {
/// The owning project.
project_id: ProjectId,
/// The task.
task_id: TaskId,
/// The agent that owns completion delivery.
owner_agent_id: AgentId,
},
/// A first-class background task changed state.
BackgroundTaskStateChanged {
/// The owning project.
project_id: ProjectId,
/// The task.
task_id: TaskId,
/// The agent that owns completion delivery.
owner_agent_id: AgentId,
/// New state.
state: crate::background_task::BackgroundTaskState,
},
/// A first-class background task completed successfully.
BackgroundTaskCompleted {
/// The owning project.
project_id: ProjectId,
/// The task.
task_id: TaskId,
/// The agent that owns completion delivery.
owner_agent_id: AgentId,
},
/// A first-class background task failed.
BackgroundTaskFailed {
/// The owning project.
project_id: ProjectId,
/// The task.
task_id: TaskId,
/// The agent that owns completion delivery.
owner_agent_id: AgentId,
},
/// A first-class background task was cancelled.
BackgroundTaskCancelled {
/// The owning project.
project_id: ProjectId,
/// The task.
task_id: TaskId,
/// The agent that owns completion delivery.
owner_agent_id: AgentId,
},
/// A first-class background task has a terminal result not yet delivered.
BackgroundTaskCompletionDeliveryPending {
/// The owning project.
project_id: ProjectId,
/// The task.
task_id: TaskId,
/// The agent that owns completion delivery.
owner_agent_id: AgentId,
},
/// A first-class background task completion was delivered.
BackgroundTaskCompletionDelivered {
/// The owning project.
project_id: ProjectId,
/// The task.
task_id: TaskId,
/// The agent that owns completion delivery.
owner_agent_id: AgentId,
},
/// An item was queued in an agent inbox.
AgentInboxQueued {
/// Target agent.
agent_id: AgentId,
/// Queue depth after enqueue.
depth: usize,
},
/// An item was drained from an agent inbox.
AgentInboxDrained {
/// Target agent.
agent_id: AgentId,
/// Queue depth after drain.
depth: usize,
},
/// A wake was scheduled for an agent.
AgentWakeScheduled {
/// Owning project.
project_id: ProjectId,
/// Target agent.
agent_id: AgentId,
},
/// A wake turn started for an agent.
AgentWakeStarted {
/// Owning project.
project_id: ProjectId,
/// Target agent.
agent_id: AgentId,
},
/// A wake attempt failed.
AgentWakeFailed {
/// Owning project.
project_id: ProjectId,
/// Target agent.
agent_id: AgentId,
/// Human-readable reason.
reason: String,
},
/// 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,
},
/// Intermediate assistant announcement emitted live during an inter-agent
/// structured turn. Ephemeral observability event: never persisted, never terminal.
AgentAnnouncement {
/// Owning project.
project_id: ProjectId,
/// Human or source agent that requested the turn.
requester: ConversationParty,
/// Target agent producing the announcement.
target: AgentId,
/// FIFO ticket correlating this turn.
ticket: TicketId,
/// Announcement text.
text: String,
/// Wall-clock timestamp in epoch milliseconds.
at_ms: u64,
},
/// An agent's process exited.
AgentExited {
/// The agent.
agent_id: AgentId,
/// Exit code.
code: i32,
},
/// A ticket assistant session was opened for an issue.
TicketAssistantOpened {
/// Bound issue.
issue_ref: IssueRef,
/// Runtime profile used by the assistant.
profile_id: ProfileId,
},
/// A ticket assistant session was closed for an issue.
TicketAssistantClosed {
/// Bound issue.
issue_ref: IssueRef,
},
/// 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 was deleted.
IssueDeleted {
/// Owning project.
project_id: ProjectId,
/// Human-friendly issue reference.
issue_ref: IssueRef,
/// Sprint membership released by the deletion, when any.
freed_sprint: Option<SprintId>,
},
/// 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 sprint was created.
SprintCreated {
/// Stable sprint id.
sprint_id: SprintId,
/// Reorderable sprint order.
order: SprintOrder,
},
/// A sprint was renamed.
SprintRenamed {
/// Stable sprint id.
sprint_id: SprintId,
/// New sprint name.
name: String,
/// New optimistic version.
version: SprintVersion,
},
/// Sprint orders changed.
SprintReordered {
/// Stable sprint id.
sprint_id: SprintId,
/// New sprint order.
order: SprintOrder,
/// New optimistic version.
version: SprintVersion,
},
/// A sprint was deleted.
SprintDeleted {
/// Stable sprint id.
sprint_id: SprintId,
},
/// An issue was moved into, out of, or between sprints.
IssueSprintChanged {
/// Human-friendly issue reference.
issue_ref: IssueRef,
/// Previous sprint.
from: Option<SprintId>,
/// New sprint.
to: Option<SprintId>,
/// 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<String>,
/// 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<String>,
/// Target profile's submit delay in ms. `None` ⇒ front default (~60 ms).
submit_delay_ms: Option<u32>,
},
/// 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<i64>,
},
/// 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<i64>,
},
/// 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<AgentId>,
},
/// Raw PTY output (usually routed to a dedicated channel, not this bus).
PtyOutput {
/// The session.
session_id: SessionId,
/// Output bytes.
bytes: Vec<u8>,
},
}
#[cfg(test)]
mod tests {
use super::*;
fn agent(n: u128) -> AgentId {
AgentId::from_uuid(uuid::Uuid::from_u128(n))
}
fn profile(n: u128) -> ProfileId {
ProfileId::from_uuid(uuid::Uuid::from_u128(n))
}
fn issue_ref(n: u64) -> IssueRef {
IssueRef::from(crate::IssueNumber::new(n).unwrap())
}
// -- §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 ticket_assistant_opened_constructs_and_compares() {
let ev = DomainEvent::TicketAssistantOpened {
issue_ref: issue_ref(7),
profile_id: profile(9),
};
assert_eq!(
ev,
DomainEvent::TicketAssistantOpened {
issue_ref: issue_ref(7),
profile_id: profile(9),
}
);
assert_ne!(
ev,
DomainEvent::TicketAssistantOpened {
issue_ref: issue_ref(8),
profile_id: profile(9),
}
);
}
#[test]
fn ticket_assistant_closed_constructs_and_compares() {
let ev = DomainEvent::TicketAssistantClosed {
issue_ref: issue_ref(7),
};
assert_eq!(
ev,
DomainEvent::TicketAssistantClosed {
issue_ref: issue_ref(7),
}
);
assert_ne!(
ev,
DomainEvent::TicketAssistantClosed {
issue_ref: issue_ref(8),
}
);
}
#[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) }
);
}
}