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>
1164 lines
42 KiB
Rust
1164 lines
42 KiB
Rust
//! `TauriEventRelay` — bridges the domain [`EventBus`] to Tauri events
|
|
//! (backend → frontend push channel, ARCHITECTURE §2 "Events").
|
|
//!
|
|
//! The relay subscribes to the bus and re-emits each [`DomainEvent`] as a Tauri
|
|
//! event named [`DOMAIN_EVENT`], carrying a serialisable [`DomainEventDto`]
|
|
//! payload (the domain event itself is deliberately not `Serialize`; the wire
|
|
//! format is owned here, the infrastructure/presentation layer).
|
|
//!
|
|
//! High-frequency `PtyOutput` is intentionally *not* relayed through this global
|
|
//! event; it goes through per-session [`crate::pty::PtyBridge`] channels instead.
|
|
|
|
use serde::Serialize;
|
|
use tauri::{AppHandle, Emitter};
|
|
|
|
use domain::conversation::ConversationParty;
|
|
use domain::events::{DomainEvent, OrchestrationSource};
|
|
use domain::input::AgentLiveness;
|
|
use domain::{IssueLinkKind, IssuePriority, IssueStatus};
|
|
use infrastructure::TokioBroadcastEventBus;
|
|
|
|
/// Name of the Tauri event carrying relayed [`DomainEvent`]s.
|
|
pub const DOMAIN_EVENT: &str = "domain://event";
|
|
|
|
/// Serialisable mirror of [`DomainEvent`] for the IPC wire (camelCase, tagged).
|
|
///
|
|
/// `type` is the discriminant; payload fields are flattened per variant. This is
|
|
/// the single owner of the event wire format on the backend side.
|
|
#[derive(Debug, Clone, Serialize)]
|
|
#[serde(tag = "type", rename_all = "camelCase")]
|
|
pub enum DomainEventDto {
|
|
/// A project was created.
|
|
#[serde(rename_all = "camelCase")]
|
|
ProjectCreated {
|
|
/// Project id (UUID string).
|
|
project_id: String,
|
|
},
|
|
/// An agent was launched.
|
|
#[serde(rename_all = "camelCase")]
|
|
AgentLaunched {
|
|
/// Agent id.
|
|
agent_id: String,
|
|
/// Session id.
|
|
session_id: String,
|
|
},
|
|
/// An agent exited.
|
|
#[serde(rename_all = "camelCase")]
|
|
AgentExited {
|
|
/// Agent id.
|
|
agent_id: String,
|
|
/// Exit code.
|
|
code: i32,
|
|
},
|
|
/// A ticket assistant chat was opened.
|
|
#[serde(rename_all = "camelCase")]
|
|
TicketAssistantOpened {
|
|
/// Bound ticket reference.
|
|
issue_ref: String,
|
|
/// Runtime profile id.
|
|
profile_id: String,
|
|
},
|
|
/// A ticket assistant chat was closed.
|
|
#[serde(rename_all = "camelCase")]
|
|
TicketAssistantClosed {
|
|
/// Bound ticket reference.
|
|
issue_ref: String,
|
|
},
|
|
/// An agent's busy/idle state changed (cadrage C4 §4.2). The frontend dims
|
|
/// "Envoyer" while `busy` is `true`.
|
|
#[serde(rename_all = "camelCase")]
|
|
AgentBusyChanged {
|
|
/// Agent id.
|
|
agent_id: String,
|
|
/// `true` when a turn is in flight, `false` when idle.
|
|
busy: bool,
|
|
},
|
|
/// A delegation is ready to be injected into the agent's **native terminal**
|
|
/// (ARCHITECTURE §20). The frontend write-portal writes `text` + `submitSequence`
|
|
/// (default `"\r"`) after `submitDelayMs` (default ~60) once the human line is empty,
|
|
/// then acks via the `delegation_delivered` command. The backend no longer PTY-writes
|
|
/// the turn.
|
|
#[serde(rename_all = "camelCase")]
|
|
DelegationReady {
|
|
/// Target agent id (UUID string).
|
|
agent_id: String,
|
|
/// Mailbox ticket id (UUID string) to ack back via `delegation_delivered`.
|
|
ticket: String,
|
|
/// Task text to inject (written without a trailing newline by the portal).
|
|
text: String,
|
|
/// Profile's submit sequence; `null` ⇒ the front applies its default (`"\r"`).
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
submit_sequence: Option<String>,
|
|
/// Profile's submit delay in ms; `null` ⇒ the front default (~60 ms).
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
submit_delay_ms: Option<u32>,
|
|
},
|
|
/// A target agent produced a synchronous reply to an inter-agent `ask` (§17.4).
|
|
#[serde(rename_all = "camelCase")]
|
|
AgentReplied {
|
|
/// Target agent id.
|
|
agent_id: String,
|
|
/// Reply length in bytes (metric, not the payload).
|
|
reply_len: usize,
|
|
},
|
|
/// Intermediate assistant announcement emitted live during an inter-agent turn.
|
|
#[serde(rename_all = "camelCase")]
|
|
AgentAnnouncement {
|
|
/// Project id.
|
|
project_id: String,
|
|
/// Requester party (`"user"` or requester agent id).
|
|
requester: String,
|
|
/// Target agent id.
|
|
target: String,
|
|
/// FIFO ticket id.
|
|
ticket_id: String,
|
|
/// Announcement text.
|
|
text: String,
|
|
/// Wall-clock timestamp in epoch milliseconds.
|
|
at_ms: u64,
|
|
},
|
|
/// An agent's liveness (alive/stalled) changed (lot 2, readiness/heartbeat). The
|
|
/// frontend can badge a frozen agent; `"stalled"` while no proof of liveness arrived
|
|
/// for longer than the profile's `stallAfterMs`, back to `"alive"` on a late
|
|
/// battement or when the turn ends.
|
|
#[serde(rename_all = "camelCase")]
|
|
AgentLivenessChanged {
|
|
/// Agent id (UUID string).
|
|
agent_id: String,
|
|
/// New liveness, as a lowercase string (`"alive"` / `"stalled"`).
|
|
liveness: AgentLivenessDto,
|
|
},
|
|
/// A background task lifecycle/delivery event occurred.
|
|
#[serde(rename_all = "camelCase")]
|
|
BackgroundTaskChanged {
|
|
/// Project id.
|
|
project_id: String,
|
|
/// Task id.
|
|
task_id: String,
|
|
/// Owner agent id.
|
|
agent_id: String,
|
|
/// Lightweight event/state label.
|
|
state: String,
|
|
},
|
|
/// An agent inbox queue depth changed.
|
|
#[serde(rename_all = "camelCase")]
|
|
AgentInboxChanged {
|
|
/// Agent id.
|
|
agent_id: String,
|
|
/// Queue depth after the operation.
|
|
depth: usize,
|
|
/// Queue operation label.
|
|
action: String,
|
|
},
|
|
/// An owner wake event occurred.
|
|
#[serde(rename_all = "camelCase")]
|
|
AgentWakeChanged {
|
|
/// Project id.
|
|
project_id: String,
|
|
/// Agent id.
|
|
agent_id: String,
|
|
/// Wake operation label.
|
|
action: String,
|
|
/// Failure reason, when any.
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
reason: Option<String>,
|
|
},
|
|
/// An agent's runtime profile was changed (hot-swap of the AI engine).
|
|
#[serde(rename_all = "camelCase")]
|
|
AgentProfileChanged {
|
|
/// Agent id.
|
|
agent_id: String,
|
|
/// The new runtime profile id.
|
|
profile_id: String,
|
|
},
|
|
/// A template was updated.
|
|
#[serde(rename_all = "camelCase")]
|
|
TemplateUpdated {
|
|
/// Template id.
|
|
template_id: String,
|
|
/// New version.
|
|
version: u64,
|
|
},
|
|
/// A synchronized agent drifted from its template.
|
|
#[serde(rename_all = "camelCase")]
|
|
AgentDriftDetected {
|
|
/// Agent id.
|
|
agent_id: String,
|
|
/// Current version.
|
|
from: u64,
|
|
/// Available version.
|
|
to: u64,
|
|
},
|
|
/// A synchronized agent was brought up to date.
|
|
#[serde(rename_all = "camelCase")]
|
|
AgentSynced {
|
|
/// Agent id.
|
|
agent_id: String,
|
|
/// Version synced to.
|
|
to: u64,
|
|
},
|
|
/// A skill was assigned to (or unassigned from) an agent.
|
|
#[serde(rename_all = "camelCase")]
|
|
SkillAssigned {
|
|
/// Agent id.
|
|
agent_id: String,
|
|
/// Skill id.
|
|
skill_id: String,
|
|
/// `true` if assigned, `false` if unassigned.
|
|
assigned: bool,
|
|
},
|
|
/// A tab's layout changed.
|
|
#[serde(rename_all = "camelCase")]
|
|
LayoutChanged {
|
|
/// Project id.
|
|
project_id: String,
|
|
},
|
|
/// A remote connection was established.
|
|
#[serde(rename_all = "camelCase")]
|
|
RemoteConnected {
|
|
/// Project id.
|
|
project_id: String,
|
|
},
|
|
/// Git state changed.
|
|
#[serde(rename_all = "camelCase")]
|
|
GitStateChanged {
|
|
/// Project id.
|
|
project_id: String,
|
|
},
|
|
/// An issue-backed public ticket was created.
|
|
#[serde(rename_all = "camelCase")]
|
|
IssueCreated {
|
|
/// Issue id.
|
|
issue_id: String,
|
|
/// Public ticket reference (`#N`).
|
|
issue_ref: String,
|
|
},
|
|
/// An issue-backed public ticket was updated.
|
|
#[serde(rename_all = "camelCase")]
|
|
IssueUpdated {
|
|
/// Public ticket reference (`#N`).
|
|
issue_ref: String,
|
|
/// New optimistic version.
|
|
version: u64,
|
|
},
|
|
/// An issue-backed public ticket was deleted.
|
|
#[serde(rename_all = "camelCase")]
|
|
IssueDeleted {
|
|
/// Project id.
|
|
project_id: String,
|
|
/// Deleted public ticket reference (`#N`).
|
|
issue_ref: String,
|
|
/// Released sprint id, when any.
|
|
freed_sprint: Option<String>,
|
|
},
|
|
/// A public ticket status changed.
|
|
#[serde(rename_all = "camelCase")]
|
|
IssueStatusChanged {
|
|
/// Public ticket reference (`#N`).
|
|
issue_ref: String,
|
|
/// New status.
|
|
status: String,
|
|
/// New optimistic version.
|
|
version: u64,
|
|
},
|
|
/// A public ticket priority changed.
|
|
#[serde(rename_all = "camelCase")]
|
|
IssuePriorityChanged {
|
|
/// Public ticket reference (`#N`).
|
|
issue_ref: String,
|
|
/// New priority.
|
|
priority: String,
|
|
/// New optimistic version.
|
|
version: u64,
|
|
},
|
|
/// A public ticket carnet changed.
|
|
#[serde(rename_all = "camelCase")]
|
|
IssueCarnetUpdated {
|
|
/// Public ticket reference (`#N`).
|
|
issue_ref: String,
|
|
/// New optimistic version.
|
|
version: u64,
|
|
},
|
|
/// A public ticket link was added.
|
|
#[serde(rename_all = "camelCase")]
|
|
IssueLinked {
|
|
/// Public source ticket reference (`#N`).
|
|
issue_ref: String,
|
|
/// Public target ticket reference (`#N`).
|
|
target: String,
|
|
/// Link kind.
|
|
kind: String,
|
|
/// New optimistic version.
|
|
version: u64,
|
|
},
|
|
/// A public ticket link was removed.
|
|
#[serde(rename_all = "camelCase")]
|
|
IssueUnlinked {
|
|
/// Public source ticket reference (`#N`).
|
|
issue_ref: String,
|
|
/// Public target ticket reference (`#N`).
|
|
target: String,
|
|
/// Link kind.
|
|
kind: String,
|
|
/// New optimistic version.
|
|
version: u64,
|
|
},
|
|
/// An agent was assigned to a public ticket.
|
|
#[serde(rename_all = "camelCase")]
|
|
IssueAgentAssigned {
|
|
/// Public ticket reference (`#N`).
|
|
issue_ref: String,
|
|
/// Assigned agent id.
|
|
agent_id: String,
|
|
/// New optimistic version.
|
|
version: u64,
|
|
},
|
|
/// An agent was unassigned from a public ticket.
|
|
#[serde(rename_all = "camelCase")]
|
|
IssueAgentUnassigned {
|
|
/// Public ticket reference (`#N`).
|
|
issue_ref: String,
|
|
/// Unassigned agent id.
|
|
agent_id: String,
|
|
/// New optimistic version.
|
|
version: u64,
|
|
},
|
|
/// A sprint was created.
|
|
#[serde(rename_all = "camelCase")]
|
|
SprintCreated {
|
|
/// Stable sprint id.
|
|
sprint_id: String,
|
|
/// Reorderable order.
|
|
order: u32,
|
|
},
|
|
/// A sprint was renamed.
|
|
#[serde(rename_all = "camelCase")]
|
|
SprintRenamed {
|
|
/// Stable sprint id.
|
|
sprint_id: String,
|
|
/// New name.
|
|
name: String,
|
|
/// New optimistic version.
|
|
version: u64,
|
|
},
|
|
/// A sprint was reordered.
|
|
#[serde(rename_all = "camelCase")]
|
|
SprintReordered {
|
|
/// Stable sprint id.
|
|
sprint_id: String,
|
|
/// New order.
|
|
order: u32,
|
|
/// New optimistic version.
|
|
version: u64,
|
|
},
|
|
/// A sprint was deleted.
|
|
#[serde(rename_all = "camelCase")]
|
|
SprintDeleted {
|
|
/// Stable sprint id.
|
|
sprint_id: String,
|
|
},
|
|
/// A ticket changed sprint membership.
|
|
#[serde(rename_all = "camelCase")]
|
|
IssueSprintChanged {
|
|
/// Public ticket reference (`#N`).
|
|
issue_ref: String,
|
|
/// Previous sprint id.
|
|
from: Option<String>,
|
|
/// New sprint id.
|
|
to: Option<String>,
|
|
/// New optimistic version.
|
|
version: u64,
|
|
},
|
|
/// An orchestrator request was processed on behalf of a requester agent.
|
|
#[serde(rename_all = "camelCase")]
|
|
OrchestratorRequestProcessed {
|
|
/// Id of the requesting (orchestrator) agent.
|
|
requester_id: String,
|
|
/// The action that was processed.
|
|
action: String,
|
|
/// Whether IdeA handled it successfully.
|
|
ok: bool,
|
|
/// Which entry door the request arrived through (`"file"` watcher vs
|
|
/// `"mcp"` server). Serialised as a lowercase string so the frontend can
|
|
/// badge the source.
|
|
source: OrchestrationSourceDto,
|
|
},
|
|
/// The project's orchestrator designation changed (T1).
|
|
#[serde(rename_all = "camelCase")]
|
|
OrchestratorChanged {
|
|
/// The project whose designation changed.
|
|
project_id: String,
|
|
/// The newly designated orchestrator agent, or `None` for the default
|
|
/// (the oldest agent orchestrates).
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
orchestrator: Option<String>,
|
|
},
|
|
/// A memory note was created or updated.
|
|
#[serde(rename_all = "camelCase")]
|
|
MemorySaved {
|
|
/// The saved note's slug.
|
|
slug: String,
|
|
},
|
|
/// A memory note was deleted.
|
|
#[serde(rename_all = "camelCase")]
|
|
MemoryDeleted {
|
|
/// The deleted note's slug.
|
|
slug: String,
|
|
},
|
|
/// The aggregated `MEMORY.md` index was rebuilt.
|
|
#[serde(rename_all = "camelCase")]
|
|
MemoryIndexRebuilt {
|
|
/// Project id.
|
|
project_id: String,
|
|
},
|
|
/// A project's memory crossed the recall budget while no embedder is configured
|
|
/// (LOT C3 — §14.5.5): a one-time, dismissible "configure an embedder?" hint.
|
|
#[serde(rename_all = "camelCase")]
|
|
EmbedderSuggested {
|
|
/// Project id.
|
|
project_id: String,
|
|
/// Whether a local Ollama-style embedding server was detected.
|
|
ollama_detected: bool,
|
|
/// Ids of recommended ONNX models already present in the local cache.
|
|
onnx_cached: Vec<String>,
|
|
/// Whether the HTTP capability is compiled in.
|
|
vector_http_enabled: bool,
|
|
/// Whether the in-process ONNX capability is compiled in.
|
|
vector_onnx_enabled: bool,
|
|
},
|
|
/// An agent entered a **session/rate limit** (ARCHITECTURE §21). Low-frequency,
|
|
/// model-agnostic badge: carries only the neutral "limited, maybe resets at T"
|
|
/// fact. The frontend badges "limité jusqu'à HH:MM".
|
|
#[serde(rename_all = "camelCase")]
|
|
AgentRateLimited {
|
|
/// Agent id (UUID string).
|
|
agent_id: String,
|
|
/// Reset instant in **epoch-milliseconds**; `null` ⇒ unknown (no auto-resume).
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
resets_at_ms: Option<i64>,
|
|
},
|
|
/// An **auto-resume** was armed for a rate-limited agent (ARCHITECTURE §21). The
|
|
/// frontend shows the countdown + the "Annuler la reprise" button (cancellable
|
|
/// window).
|
|
#[serde(rename_all = "camelCase")]
|
|
AgentResumeScheduled {
|
|
/// Agent id (UUID string).
|
|
agent_id: String,
|
|
/// Wake-up deadline in **epoch-milliseconds**.
|
|
fire_at_ms: i64,
|
|
},
|
|
/// An agent's **auto-resume** was **cancelled** (ARCHITECTURE §21): the user
|
|
/// clicked "Annuler la reprise". The frontend removes the countdown.
|
|
#[serde(rename_all = "camelCase")]
|
|
AgentResumeCancelled {
|
|
/// Agent id (UUID string).
|
|
agent_id: String,
|
|
},
|
|
/// An agent was effectively **resumed** after a limit (ARCHITECTURE §21): the
|
|
/// wake-up fired (or immediate resume). The frontend clears the "limited" state.
|
|
#[serde(rename_all = "camelCase")]
|
|
AgentResumed {
|
|
/// Agent id (UUID string).
|
|
agent_id: String,
|
|
},
|
|
/// **Human net (level 3)**: a session limit is **suspected** without any reliable
|
|
/// reset time (ARCHITECTURE §21.1). IdeA never resumes blind: this asks the front
|
|
/// to **prompt the user** ("limit detected but time unknown — resume at?").
|
|
#[serde(rename_all = "camelCase")]
|
|
AgentRateLimitSuspected {
|
|
/// Agent id (UUID string).
|
|
agent_id: String,
|
|
/// Reset instant in **epoch-milliseconds** if an estimate exists, else `null`.
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
resets_at_ms: Option<i64>,
|
|
},
|
|
/// Raw PTY output (normally routed to a per-session channel, not here).
|
|
#[serde(rename_all = "camelCase")]
|
|
PtyOutput {
|
|
/// Session id.
|
|
session_id: String,
|
|
/// Output bytes.
|
|
bytes: Vec<u8>,
|
|
},
|
|
}
|
|
|
|
/// Wire mirror of [`OrchestrationSource`]: which entry door a processed
|
|
/// orchestration request arrived through. Serialised as a lowercase string
|
|
/// (`"file"` / `"mcp"`) the frontend badges on the event.
|
|
#[derive(Debug, Clone, Copy, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub enum OrchestrationSourceDto {
|
|
/// A `.ideai/requests` JSON file (filesystem watcher).
|
|
File,
|
|
/// A `tools/call` on the MCP server.
|
|
Mcp,
|
|
}
|
|
|
|
/// Wire mirror of [`AgentLiveness`]: the alive/stalled liveness of an agent (lot 2),
|
|
/// serialised as a lowercase string (`"alive"` / `"stalled"`) the frontend badges.
|
|
#[derive(Debug, Clone, Copy, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub enum AgentLivenessDto {
|
|
/// The agent shows proof of liveness (or is idle).
|
|
Alive,
|
|
/// No proof of liveness past the profile's `stallAfterMs` threshold.
|
|
Stalled,
|
|
}
|
|
|
|
impl From<AgentLiveness> for AgentLivenessDto {
|
|
fn from(liveness: AgentLiveness) -> Self {
|
|
match liveness {
|
|
AgentLiveness::Alive => Self::Alive,
|
|
AgentLiveness::Stalled => Self::Stalled,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<OrchestrationSource> for OrchestrationSourceDto {
|
|
fn from(source: OrchestrationSource) -> Self {
|
|
match source {
|
|
OrchestrationSource::File => Self::File,
|
|
OrchestrationSource::Mcp => Self::Mcp,
|
|
}
|
|
}
|
|
}
|
|
|
|
fn issue_status_wire(status: IssueStatus) -> &'static str {
|
|
match status {
|
|
IssueStatus::Open => "open",
|
|
IssueStatus::InProgress => "inProgress",
|
|
IssueStatus::Qa => "QA",
|
|
IssueStatus::Closed => "closed",
|
|
}
|
|
}
|
|
|
|
fn issue_priority_wire(priority: IssuePriority) -> &'static str {
|
|
match priority {
|
|
IssuePriority::Low => "low",
|
|
IssuePriority::Medium => "medium",
|
|
IssuePriority::High => "high",
|
|
IssuePriority::Critical => "critical",
|
|
}
|
|
}
|
|
|
|
fn issue_link_kind_wire(kind: IssueLinkKind) -> &'static str {
|
|
match kind {
|
|
IssueLinkKind::RelatesTo => "relatesTo",
|
|
IssueLinkKind::Blocks => "blocks",
|
|
IssueLinkKind::BlockedBy => "blockedBy",
|
|
IssueLinkKind::Duplicates => "duplicates",
|
|
IssueLinkKind::DependsOn => "dependsOn",
|
|
}
|
|
}
|
|
|
|
fn conversation_party_wire(party: ConversationParty) -> String {
|
|
match party {
|
|
ConversationParty::User => "user".to_owned(),
|
|
ConversationParty::Agent { agent_id } => agent_id.to_string(),
|
|
}
|
|
}
|
|
|
|
impl From<&DomainEvent> for DomainEventDto {
|
|
fn from(e: &DomainEvent) -> Self {
|
|
match e {
|
|
DomainEvent::ProjectCreated { project_id } => Self::ProjectCreated {
|
|
project_id: project_id.to_string(),
|
|
},
|
|
DomainEvent::AgentLaunched {
|
|
agent_id,
|
|
session_id,
|
|
} => Self::AgentLaunched {
|
|
agent_id: agent_id.to_string(),
|
|
session_id: session_id.to_string(),
|
|
},
|
|
DomainEvent::AgentExited { agent_id, code } => Self::AgentExited {
|
|
agent_id: agent_id.to_string(),
|
|
code: *code,
|
|
},
|
|
DomainEvent::TicketAssistantOpened {
|
|
issue_ref,
|
|
profile_id,
|
|
} => Self::TicketAssistantOpened {
|
|
issue_ref: issue_ref.to_string(),
|
|
profile_id: profile_id.to_string(),
|
|
},
|
|
DomainEvent::TicketAssistantClosed { issue_ref } => Self::TicketAssistantClosed {
|
|
issue_ref: issue_ref.to_string(),
|
|
},
|
|
DomainEvent::AgentBusyChanged { agent_id, busy } => Self::AgentBusyChanged {
|
|
agent_id: agent_id.to_string(),
|
|
busy: *busy,
|
|
},
|
|
DomainEvent::DelegationReady {
|
|
agent_id,
|
|
ticket,
|
|
text,
|
|
submit_sequence,
|
|
submit_delay_ms,
|
|
} => Self::DelegationReady {
|
|
agent_id: agent_id.to_string(),
|
|
ticket: ticket.to_string(),
|
|
text: text.clone(),
|
|
submit_sequence: submit_sequence.clone(),
|
|
submit_delay_ms: *submit_delay_ms,
|
|
},
|
|
DomainEvent::AgentReplied {
|
|
agent_id,
|
|
reply_len,
|
|
} => Self::AgentReplied {
|
|
agent_id: agent_id.to_string(),
|
|
reply_len: *reply_len,
|
|
},
|
|
DomainEvent::AgentAnnouncement {
|
|
project_id,
|
|
requester,
|
|
target,
|
|
ticket,
|
|
text,
|
|
at_ms,
|
|
} => Self::AgentAnnouncement {
|
|
project_id: project_id.to_string(),
|
|
requester: conversation_party_wire(*requester),
|
|
target: target.to_string(),
|
|
ticket_id: ticket.to_string(),
|
|
text: text.clone(),
|
|
at_ms: *at_ms,
|
|
},
|
|
DomainEvent::AgentLivenessChanged { agent_id, liveness } => {
|
|
Self::AgentLivenessChanged {
|
|
agent_id: agent_id.to_string(),
|
|
liveness: (*liveness).into(),
|
|
}
|
|
}
|
|
DomainEvent::BackgroundTaskStarted {
|
|
project_id,
|
|
task_id,
|
|
owner_agent_id,
|
|
} => Self::BackgroundTaskChanged {
|
|
project_id: project_id.to_string(),
|
|
task_id: task_id.to_string(),
|
|
agent_id: owner_agent_id.to_string(),
|
|
state: "started".to_owned(),
|
|
},
|
|
DomainEvent::BackgroundTaskStateChanged {
|
|
project_id,
|
|
task_id,
|
|
owner_agent_id,
|
|
state,
|
|
} => Self::BackgroundTaskChanged {
|
|
project_id: project_id.to_string(),
|
|
task_id: task_id.to_string(),
|
|
agent_id: owner_agent_id.to_string(),
|
|
state: format!("{state:?}"),
|
|
},
|
|
DomainEvent::BackgroundTaskCompleted {
|
|
project_id,
|
|
task_id,
|
|
owner_agent_id,
|
|
} => Self::BackgroundTaskChanged {
|
|
project_id: project_id.to_string(),
|
|
task_id: task_id.to_string(),
|
|
agent_id: owner_agent_id.to_string(),
|
|
state: "completed".to_owned(),
|
|
},
|
|
DomainEvent::BackgroundTaskFailed {
|
|
project_id,
|
|
task_id,
|
|
owner_agent_id,
|
|
} => Self::BackgroundTaskChanged {
|
|
project_id: project_id.to_string(),
|
|
task_id: task_id.to_string(),
|
|
agent_id: owner_agent_id.to_string(),
|
|
state: "failed".to_owned(),
|
|
},
|
|
DomainEvent::BackgroundTaskCancelled {
|
|
project_id,
|
|
task_id,
|
|
owner_agent_id,
|
|
} => Self::BackgroundTaskChanged {
|
|
project_id: project_id.to_string(),
|
|
task_id: task_id.to_string(),
|
|
agent_id: owner_agent_id.to_string(),
|
|
state: "cancelled".to_owned(),
|
|
},
|
|
DomainEvent::BackgroundTaskCompletionDeliveryPending {
|
|
project_id,
|
|
task_id,
|
|
owner_agent_id,
|
|
} => Self::BackgroundTaskChanged {
|
|
project_id: project_id.to_string(),
|
|
task_id: task_id.to_string(),
|
|
agent_id: owner_agent_id.to_string(),
|
|
state: "deliveryPending".to_owned(),
|
|
},
|
|
DomainEvent::BackgroundTaskCompletionDelivered {
|
|
project_id,
|
|
task_id,
|
|
owner_agent_id,
|
|
} => Self::BackgroundTaskChanged {
|
|
project_id: project_id.to_string(),
|
|
task_id: task_id.to_string(),
|
|
agent_id: owner_agent_id.to_string(),
|
|
state: "delivered".to_owned(),
|
|
},
|
|
DomainEvent::AgentInboxQueued { agent_id, depth } => Self::AgentInboxChanged {
|
|
agent_id: agent_id.to_string(),
|
|
depth: *depth,
|
|
action: "queued".to_owned(),
|
|
},
|
|
DomainEvent::AgentInboxDrained { agent_id, depth } => Self::AgentInboxChanged {
|
|
agent_id: agent_id.to_string(),
|
|
depth: *depth,
|
|
action: "drained".to_owned(),
|
|
},
|
|
DomainEvent::AgentWakeScheduled {
|
|
project_id,
|
|
agent_id,
|
|
} => Self::AgentWakeChanged {
|
|
project_id: project_id.to_string(),
|
|
agent_id: agent_id.to_string(),
|
|
action: "scheduled".to_owned(),
|
|
reason: None,
|
|
},
|
|
DomainEvent::AgentWakeStarted {
|
|
project_id,
|
|
agent_id,
|
|
} => Self::AgentWakeChanged {
|
|
project_id: project_id.to_string(),
|
|
agent_id: agent_id.to_string(),
|
|
action: "started".to_owned(),
|
|
reason: None,
|
|
},
|
|
DomainEvent::AgentWakeFailed {
|
|
project_id,
|
|
agent_id,
|
|
reason,
|
|
} => Self::AgentWakeChanged {
|
|
project_id: project_id.to_string(),
|
|
agent_id: agent_id.to_string(),
|
|
action: "failed".to_owned(),
|
|
reason: Some(reason.clone()),
|
|
},
|
|
DomainEvent::AgentProfileChanged {
|
|
agent_id,
|
|
profile_id,
|
|
} => Self::AgentProfileChanged {
|
|
agent_id: agent_id.to_string(),
|
|
profile_id: profile_id.to_string(),
|
|
},
|
|
DomainEvent::TemplateUpdated {
|
|
template_id,
|
|
version,
|
|
} => Self::TemplateUpdated {
|
|
template_id: template_id.to_string(),
|
|
version: version.get(),
|
|
},
|
|
DomainEvent::AgentDriftDetected { agent_id, from, to } => Self::AgentDriftDetected {
|
|
agent_id: agent_id.to_string(),
|
|
from: from.get(),
|
|
to: to.get(),
|
|
},
|
|
DomainEvent::AgentSynced { agent_id, to } => Self::AgentSynced {
|
|
agent_id: agent_id.to_string(),
|
|
to: to.get(),
|
|
},
|
|
DomainEvent::SkillAssigned {
|
|
agent_id,
|
|
skill_id,
|
|
assigned,
|
|
} => Self::SkillAssigned {
|
|
agent_id: agent_id.to_string(),
|
|
skill_id: skill_id.to_string(),
|
|
assigned: *assigned,
|
|
},
|
|
DomainEvent::LayoutChanged { project_id } => Self::LayoutChanged {
|
|
project_id: project_id.to_string(),
|
|
},
|
|
DomainEvent::RemoteConnected { project_id } => Self::RemoteConnected {
|
|
project_id: project_id.to_string(),
|
|
},
|
|
DomainEvent::GitStateChanged { project_id } => Self::GitStateChanged {
|
|
project_id: project_id.to_string(),
|
|
},
|
|
DomainEvent::IssueCreated {
|
|
issue_id,
|
|
issue_ref,
|
|
} => Self::IssueCreated {
|
|
issue_id: issue_id.to_string(),
|
|
issue_ref: issue_ref.to_string(),
|
|
},
|
|
DomainEvent::IssueUpdated { issue_ref, version } => Self::IssueUpdated {
|
|
issue_ref: issue_ref.to_string(),
|
|
version: version.get(),
|
|
},
|
|
DomainEvent::IssueDeleted {
|
|
project_id,
|
|
issue_ref,
|
|
freed_sprint,
|
|
} => Self::IssueDeleted {
|
|
project_id: project_id.to_string(),
|
|
issue_ref: issue_ref.to_string(),
|
|
freed_sprint: freed_sprint.map(|sprint| sprint.to_string()),
|
|
},
|
|
DomainEvent::IssueStatusChanged {
|
|
issue_ref,
|
|
status,
|
|
version,
|
|
} => Self::IssueStatusChanged {
|
|
issue_ref: issue_ref.to_string(),
|
|
status: issue_status_wire(*status).to_owned(),
|
|
version: version.get(),
|
|
},
|
|
DomainEvent::IssuePriorityChanged {
|
|
issue_ref,
|
|
priority,
|
|
version,
|
|
} => Self::IssuePriorityChanged {
|
|
issue_ref: issue_ref.to_string(),
|
|
priority: issue_priority_wire(*priority).to_owned(),
|
|
version: version.get(),
|
|
},
|
|
DomainEvent::IssueCarnetUpdated { issue_ref, version } => Self::IssueCarnetUpdated {
|
|
issue_ref: issue_ref.to_string(),
|
|
version: version.get(),
|
|
},
|
|
DomainEvent::IssueLinked {
|
|
issue_ref,
|
|
target,
|
|
kind,
|
|
version,
|
|
} => Self::IssueLinked {
|
|
issue_ref: issue_ref.to_string(),
|
|
target: target.to_string(),
|
|
kind: issue_link_kind_wire(*kind).to_owned(),
|
|
version: version.get(),
|
|
},
|
|
DomainEvent::IssueUnlinked {
|
|
issue_ref,
|
|
target,
|
|
kind,
|
|
version,
|
|
} => Self::IssueUnlinked {
|
|
issue_ref: issue_ref.to_string(),
|
|
target: target.to_string(),
|
|
kind: issue_link_kind_wire(*kind).to_owned(),
|
|
version: version.get(),
|
|
},
|
|
DomainEvent::IssueAgentAssigned {
|
|
issue_ref,
|
|
agent_id,
|
|
version,
|
|
} => Self::IssueAgentAssigned {
|
|
issue_ref: issue_ref.to_string(),
|
|
agent_id: agent_id.to_string(),
|
|
version: version.get(),
|
|
},
|
|
DomainEvent::IssueAgentUnassigned {
|
|
issue_ref,
|
|
agent_id,
|
|
version,
|
|
} => Self::IssueAgentUnassigned {
|
|
issue_ref: issue_ref.to_string(),
|
|
agent_id: agent_id.to_string(),
|
|
version: version.get(),
|
|
},
|
|
DomainEvent::SprintCreated { sprint_id, order } => Self::SprintCreated {
|
|
sprint_id: sprint_id.to_string(),
|
|
order: order.get(),
|
|
},
|
|
DomainEvent::SprintRenamed {
|
|
sprint_id,
|
|
name,
|
|
version,
|
|
} => Self::SprintRenamed {
|
|
sprint_id: sprint_id.to_string(),
|
|
name: name.clone(),
|
|
version: version.get(),
|
|
},
|
|
DomainEvent::SprintReordered {
|
|
sprint_id,
|
|
order,
|
|
version,
|
|
} => Self::SprintReordered {
|
|
sprint_id: sprint_id.to_string(),
|
|
order: order.get(),
|
|
version: version.get(),
|
|
},
|
|
DomainEvent::SprintDeleted { sprint_id } => Self::SprintDeleted {
|
|
sprint_id: sprint_id.to_string(),
|
|
},
|
|
DomainEvent::IssueSprintChanged {
|
|
issue_ref,
|
|
from,
|
|
to,
|
|
version,
|
|
} => Self::IssueSprintChanged {
|
|
issue_ref: issue_ref.to_string(),
|
|
from: from.map(|id| id.to_string()),
|
|
to: to.map(|id| id.to_string()),
|
|
version: version.get(),
|
|
},
|
|
DomainEvent::OrchestratorRequestProcessed {
|
|
requester_id,
|
|
action,
|
|
ok,
|
|
source,
|
|
} => Self::OrchestratorRequestProcessed {
|
|
requester_id: requester_id.clone(),
|
|
action: action.clone(),
|
|
ok: *ok,
|
|
source: (*source).into(),
|
|
},
|
|
DomainEvent::OrchestratorChanged {
|
|
project_id,
|
|
orchestrator,
|
|
} => Self::OrchestratorChanged {
|
|
project_id: project_id.to_string(),
|
|
orchestrator: orchestrator.as_ref().map(|a| a.to_string()),
|
|
},
|
|
DomainEvent::MemorySaved { slug } => Self::MemorySaved {
|
|
slug: slug.as_str().to_string(),
|
|
},
|
|
DomainEvent::MemoryDeleted { slug } => Self::MemoryDeleted {
|
|
slug: slug.as_str().to_string(),
|
|
},
|
|
DomainEvent::MemoryIndexRebuilt { project_id } => Self::MemoryIndexRebuilt {
|
|
project_id: project_id.to_string(),
|
|
},
|
|
DomainEvent::EmbedderSuggested {
|
|
project_id,
|
|
ollama_detected,
|
|
onnx_cached,
|
|
vector_http_enabled,
|
|
vector_onnx_enabled,
|
|
} => Self::EmbedderSuggested {
|
|
project_id: project_id.to_string(),
|
|
ollama_detected: *ollama_detected,
|
|
onnx_cached: onnx_cached.clone(),
|
|
vector_http_enabled: *vector_http_enabled,
|
|
vector_onnx_enabled: *vector_onnx_enabled,
|
|
},
|
|
DomainEvent::AgentRateLimited {
|
|
agent_id,
|
|
resets_at_ms,
|
|
} => Self::AgentRateLimited {
|
|
agent_id: agent_id.to_string(),
|
|
resets_at_ms: *resets_at_ms,
|
|
},
|
|
DomainEvent::AgentResumeScheduled {
|
|
agent_id,
|
|
fire_at_ms,
|
|
} => Self::AgentResumeScheduled {
|
|
agent_id: agent_id.to_string(),
|
|
fire_at_ms: *fire_at_ms,
|
|
},
|
|
DomainEvent::AgentResumeCancelled { agent_id } => Self::AgentResumeCancelled {
|
|
agent_id: agent_id.to_string(),
|
|
},
|
|
DomainEvent::AgentResumed { agent_id } => Self::AgentResumed {
|
|
agent_id: agent_id.to_string(),
|
|
},
|
|
DomainEvent::AgentRateLimitSuspected {
|
|
agent_id,
|
|
resets_at_ms,
|
|
} => Self::AgentRateLimitSuspected {
|
|
agent_id: agent_id.to_string(),
|
|
resets_at_ms: *resets_at_ms,
|
|
},
|
|
DomainEvent::PtyOutput { session_id, bytes } => Self::PtyOutput {
|
|
session_id: session_id.to_string(),
|
|
bytes: bytes.clone(),
|
|
},
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Subscribes the relay to the bus and spawns a background task that forwards
|
|
/// every [`DomainEvent`] to the frontend as a [`DOMAIN_EVENT`] Tauri event.
|
|
///
|
|
/// Uses the bus's raw async broadcast receiver so the relay runs cooperatively
|
|
/// on the Tokio runtime (no blocking thread). Returns immediately; the spawned
|
|
/// task lives for the duration of the app.
|
|
pub fn spawn_relay(app: AppHandle, bus: &TokioBroadcastEventBus) {
|
|
use tokio::sync::broadcast::error::RecvError;
|
|
|
|
let mut rx = bus.raw_receiver();
|
|
tauri::async_runtime::spawn(async move {
|
|
loop {
|
|
match rx.recv().await {
|
|
Ok(event) => {
|
|
// Skip high-frequency PTY output on the global channel.
|
|
if matches!(event, DomainEvent::PtyOutput { .. }) {
|
|
continue;
|
|
}
|
|
let dto = DomainEventDto::from(&event);
|
|
let _ = app.emit(DOMAIN_EVENT, dto);
|
|
}
|
|
// The bus dropped some events for this slow receiver; keep going.
|
|
Err(RecvError::Lagged(_)) => continue,
|
|
// The bus was dropped (app shutting down); stop the relay.
|
|
Err(RecvError::Closed) => break,
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use domain::ids::AgentId;
|
|
use domain::mailbox::TicketId;
|
|
use domain::ProjectId;
|
|
use serde_json::json;
|
|
|
|
fn agent(n: u128) -> AgentId {
|
|
AgentId::from_uuid(uuid::Uuid::from_u128(n))
|
|
}
|
|
|
|
/// Lot 2 : un `AgentLivenessChanged{Stalled}` du domaine se relaie en DTO
|
|
/// `Stalled` portant le même agent, et se sérialise en `"stalled"` (le mot que
|
|
/// le front badge). Garantit le câblage présentation de la détection de stall.
|
|
#[test]
|
|
fn liveness_changed_stalled_relays_to_dto_and_wire() {
|
|
let dto = DomainEventDto::from(&DomainEvent::AgentLivenessChanged {
|
|
agent_id: agent(7),
|
|
liveness: AgentLiveness::Stalled,
|
|
});
|
|
let json = serde_json::to_value(&dto).expect("serialisable");
|
|
assert_eq!(json["type"], "agentLivenessChanged");
|
|
assert_eq!(json["agentId"], agent(7).to_string());
|
|
assert_eq!(json["liveness"], "stalled");
|
|
}
|
|
|
|
/// La reprise `Stalled→Alive` se relaie en DTO `Alive` ⇒ wire `"alive"`.
|
|
#[test]
|
|
fn liveness_changed_alive_relays_to_dto_and_wire() {
|
|
let dto = DomainEventDto::from(&DomainEvent::AgentLivenessChanged {
|
|
agent_id: agent(3),
|
|
liveness: AgentLiveness::Alive,
|
|
});
|
|
let json = serde_json::to_value(&dto).expect("serialisable");
|
|
assert_eq!(json["type"], "agentLivenessChanged");
|
|
assert_eq!(json["liveness"], "alive");
|
|
}
|
|
|
|
#[test]
|
|
fn agent_announcement_relays_to_camel_case_wire_with_requester_party() {
|
|
let project_id = ProjectId::from_uuid(uuid::Uuid::from_u128(1));
|
|
let requester_agent = agent(2);
|
|
let target = agent(3);
|
|
let ticket = TicketId::from_uuid(uuid::Uuid::from_u128(4));
|
|
|
|
let human = DomainEventDto::from(&DomainEvent::AgentAnnouncement {
|
|
project_id,
|
|
requester: ConversationParty::User,
|
|
target,
|
|
ticket,
|
|
text: "statut humain".into(),
|
|
at_ms: 123_456,
|
|
});
|
|
assert_eq!(
|
|
serde_json::to_value(&human).unwrap(),
|
|
json!({
|
|
"type": "agentAnnouncement",
|
|
"projectId": project_id.to_string(),
|
|
"requester": "user",
|
|
"target": target.to_string(),
|
|
"ticketId": ticket.to_string(),
|
|
"text": "statut humain",
|
|
"atMs": 123456,
|
|
})
|
|
);
|
|
|
|
let agent_requester = DomainEventDto::from(&DomainEvent::AgentAnnouncement {
|
|
project_id,
|
|
requester: ConversationParty::agent(requester_agent),
|
|
target,
|
|
ticket,
|
|
text: "statut agent".into(),
|
|
at_ms: 654_321,
|
|
});
|
|
assert_eq!(
|
|
serde_json::to_value(&agent_requester).unwrap(),
|
|
json!({
|
|
"type": "agentAnnouncement",
|
|
"projectId": project_id.to_string(),
|
|
"requester": requester_agent.to_string(),
|
|
"target": target.to_string(),
|
|
"ticketId": ticket.to_string(),
|
|
"text": "statut agent",
|
|
"atMs": 654321,
|
|
})
|
|
);
|
|
}
|
|
|
|
/// LS6 : un `AgentRateLimited` du domaine se relaie en DTO portant le même agent
|
|
/// et l'heure de reset (époche-ms), et se sérialise en `"agentRateLimited"` avec
|
|
/// `resetsAtMs` — le fait neutre que le front badge « limité jusqu'à HH:MM ».
|
|
#[test]
|
|
fn rate_limited_relays_to_dto_and_wire() {
|
|
let dto = DomainEventDto::from(&DomainEvent::AgentRateLimited {
|
|
agent_id: agent(11),
|
|
resets_at_ms: Some(1_700_000_000_000),
|
|
});
|
|
let json = serde_json::to_value(&dto).expect("serialisable");
|
|
assert_eq!(json["type"], "agentRateLimited");
|
|
assert_eq!(json["agentId"], agent(11).to_string());
|
|
assert_eq!(json["resetsAtMs"], 1_700_000_000_000_i64);
|
|
}
|
|
|
|
#[test]
|
|
fn issue_sprint_changed_relays_to_dto_and_wire() {
|
|
let from = domain::SprintId::from_uuid(uuid::Uuid::from_u128(41));
|
|
let to = domain::SprintId::from_uuid(uuid::Uuid::from_u128(42));
|
|
let dto = DomainEventDto::from(&DomainEvent::IssueSprintChanged {
|
|
issue_ref: domain::IssueRef::from(domain::IssueNumber::new(7).unwrap()),
|
|
from: Some(from),
|
|
to: Some(to),
|
|
version: domain::IssueVersion::new(3).unwrap(),
|
|
});
|
|
let json = serde_json::to_value(&dto).expect("serialisable");
|
|
assert_eq!(json["type"], "issueSprintChanged");
|
|
assert_eq!(json["issueRef"], "#7");
|
|
assert_eq!(json["from"], from.to_string());
|
|
assert_eq!(json["to"], to.to_string());
|
|
assert_eq!(json["version"], 3);
|
|
}
|
|
|
|
#[test]
|
|
fn issue_deleted_relays_to_dto_and_wire() {
|
|
let project_id = domain::ProjectId::from_uuid(uuid::Uuid::from_u128(40));
|
|
let sprint_id = domain::SprintId::from_uuid(uuid::Uuid::from_u128(42));
|
|
let dto = DomainEventDto::from(&DomainEvent::IssueDeleted {
|
|
project_id,
|
|
issue_ref: domain::IssueRef::from(domain::IssueNumber::new(7).unwrap()),
|
|
freed_sprint: Some(sprint_id),
|
|
});
|
|
let json = serde_json::to_value(&dto).expect("serialisable");
|
|
assert_eq!(json["type"], "issueDeleted");
|
|
assert_eq!(json["projectId"], project_id.to_string());
|
|
assert_eq!(json["issueRef"], "#7");
|
|
assert_eq!(json["freedSprint"], sprint_id.to_string());
|
|
}
|
|
|
|
#[test]
|
|
fn ticket_assistant_events_relay_to_dto_and_wire() {
|
|
let profile_id = domain::ProfileId::from_uuid(uuid::Uuid::from_u128(9));
|
|
let opened = DomainEventDto::from(&DomainEvent::TicketAssistantOpened {
|
|
issue_ref: domain::IssueRef::from(domain::IssueNumber::new(7).unwrap()),
|
|
profile_id,
|
|
});
|
|
let opened = serde_json::to_value(&opened).expect("serialisable");
|
|
assert_eq!(opened["type"], "ticketAssistantOpened");
|
|
assert_eq!(opened["issueRef"], "#7");
|
|
assert_eq!(opened["profileId"], profile_id.to_string());
|
|
|
|
let closed = DomainEventDto::from(&DomainEvent::TicketAssistantClosed {
|
|
issue_ref: domain::IssueRef::from(domain::IssueNumber::new(7).unwrap()),
|
|
});
|
|
let closed = serde_json::to_value(&closed).expect("serialisable");
|
|
assert_eq!(closed["type"], "ticketAssistantClosed");
|
|
assert_eq!(closed["issueRef"], "#7");
|
|
}
|
|
}
|