diff --git a/Cargo.lock b/Cargo.lock index 68c30c7..f66cdb9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -90,6 +90,7 @@ dependencies = [ "thiserror 2.0.18", "tokio", "uuid", + "web-server", ] [[package]] @@ -5236,6 +5237,24 @@ dependencies = [ "semver", ] +[[package]] +name = "web-server" +version = "0.3.0" +dependencies = [ + "application", + "backend", + "base64 0.22.1", + "bytes", + "cookie", + "domain", + "http", + "http-body-util", + "serde", + "serde_json", + "tokio", + "uuid", +] + [[package]] name = "web-sys" version = "0.3.99" diff --git a/Cargo.toml b/Cargo.toml index ff5bd0c..a1fe65e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ members = [ "crates/application", "crates/infrastructure", "crates/backend", + "crates/web-server", "crates/app-tauri", ] @@ -31,6 +32,7 @@ domain = { path = "crates/domain" } application = { path = "crates/application" } infrastructure = { path = "crates/infrastructure" } backend = { path = "crates/backend" } +web-server = { path = "crates/web-server" } # Tauri v2 tauri = { version = "2", features = [] } diff --git a/crates/app-tauri/Cargo.toml b/crates/app-tauri/Cargo.toml index 219c52e..7ed3c2d 100644 --- a/crates/app-tauri/Cargo.toml +++ b/crates/app-tauri/Cargo.toml @@ -24,6 +24,7 @@ domain = { workspace = true } application = { workspace = true } infrastructure = { workspace = true } backend = { workspace = true } +web-server = { workspace = true } tauri = { workspace = true } tauri-plugin-dialog = { workspace = true } # `io-std` gives the headless `mcp-server` bridge access to diff --git a/crates/app-tauri/src/dto.rs b/crates/app-tauri/src/dto.rs index dae6ce4..229a908 100644 --- a/crates/app-tauri/src/dto.rs +++ b/crates/app-tauri/src/dto.rs @@ -1,3484 +1,7 @@ -//! Data Transfer Objects crossing the IPC boundary. +//! Compatibility re-export for the historical desktop DTO module. //! -//! Convention (frozen here for L1, see L1-ipc-bridge.md "points d'attention"): -//! **all IPC payloads are `camelCase`** via `#[serde(rename_all = "camelCase")]`. -//! Rust uses `snake_case` fields; serde renames them on the wire so the -//! TypeScript side sees idiomatic camelCase. This matches the persisted-domain -//! JSON convention already used in the domain (`agents.json` etc.). +//! The stable JSON contracts now live in `backend::dto` so the headless +//! `web-server` crate and the Tauri commands consume the same transport-neutral +//! DTO definitions. -use serde::{Deserialize, Serialize}; - -use application::{ - AgentBackgroundTaskState, AgentTicketState, AppError, AttachLiveAgentOutput, - BackgroundTaskKindLabel, ConversationPreviewStatus, ConversationTurnWorkPreview, - ConversationWorkSummary, CreateProjectInput, CreateProjectOutput, GitGraphOutput, HealthInput, - HealthReport, LayoutKind, ListProjectsOutput, LiveSessionKind, LiveSessionSnapshot, - OpenProjectOutput, ProjectWorkState, StopLiveAgentOutput, TicketWorkSource, TicketWorkStatus, - TurnPage, TurnSource, TurnView, -}; -use domain::{AgentBusyState, PageCursor, PageDirection, Project, ProjectId, TurnRole}; - -/// Request DTO for the `health` command. -#[derive(Debug, Clone, Default, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct HealthRequestDto { - /// Optional note echoed back by the use case. - #[serde(default)] - pub note: Option, -} - -impl From for HealthInput { - fn from(dto: HealthRequestDto) -> Self { - Self { note: dto.note } - } -} - -/// Response DTO for the `health` command. -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct HealthResponseDto { - /// Application version. - pub version: String, - /// Liveness flag. - pub alive: bool, - /// Server time in epoch milliseconds. - pub time_millis: i64, - /// Correlation id for this call. - pub correlation_id: String, - /// Echoed note, if any. - pub note: Option, -} - -impl From for HealthResponseDto { - fn from(r: HealthReport) -> Self { - Self { - version: r.version, - alive: r.alive, - time_millis: r.time_millis, - correlation_id: r.correlation_id, - note: r.note, - } - } -} - -/// Error DTO returned to the frontend in the `Err` arm of every command. -/// -/// `code` is a stable machine-readable string (see [`AppError::code`]); the -/// frontend branches on it without parsing `message`. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ErrorDto { - /// Stable error code, e.g. `NOT_FOUND`, `INVALID`. - pub code: String, - /// Human-readable message. - pub message: String, -} - -impl From for ErrorDto { - fn from(e: AppError) -> Self { - Self { - code: e.code().to_owned(), - message: e.to_string(), - } - } -} - -// --------------------------------------------------------------------------- -// Projects (L2) -// --------------------------------------------------------------------------- - -/// A project as seen by the frontend (camelCase wire shape). -/// -/// `remote` is the domain [`domain::RemoteRef`], which already serialises -/// camelCase + tagged (`kind`), so we embed it directly. -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct ProjectDto { - /// Stable project id (UUID string). - pub id: String, - /// Display name. - pub name: String, - /// Absolute project root. - pub root: String, - /// Where the project lives (`{ "kind": "local" }`, `ssh`, `wsl`). - pub remote: domain::RemoteRef, - /// Creation timestamp, epoch milliseconds. - pub created_at: i64, -} - -impl From for ProjectDto { - fn from(p: Project) -> Self { - Self { - id: p.id.to_string(), - name: p.name, - root: p.root.as_str().to_owned(), - remote: p.remote, - created_at: p.created_at, - } - } -} - -/// Request DTO for `create_project`. -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct CreateProjectRequestDto { - /// Display name. - pub name: String, - /// Absolute project root. - pub root: String, - /// Optional remote reference; defaults to local when omitted. - #[serde(default)] - pub remote: Option, - /// Optional default profile id. - #[serde(default)] - pub default_profile_id: Option, -} - -impl From for CreateProjectInput { - fn from(dto: CreateProjectRequestDto) -> Self { - Self { - name: dto.name, - root: dto.root, - remote: dto.remote, - default_profile_id: dto.default_profile_id, - } - } -} - -impl From for ProjectDto { - fn from(out: CreateProjectOutput) -> Self { - out.project.into() - } -} - -impl From for ProjectDto { - fn from(out: OpenProjectOutput) -> Self { - out.project.into() - } -} - -/// Parses a project-id string (UUID) coming from the frontend. -/// -/// # Errors -/// Returns an [`ErrorDto`] with code `INVALID` if the string is not a UUID. -pub fn parse_project_id(raw: &str) -> Result { - uuid::Uuid::parse_str(raw) - .map(ProjectId::from_uuid) - .map_err(|_| ErrorDto { - code: "INVALID".to_owned(), - message: format!("invalid project id: {raw}"), - }) -} - -/// Response DTO for `list_projects`. -#[derive(Debug, Clone, Serialize)] -#[serde(transparent)] -pub struct ProjectListDto(pub Vec); - -impl From for ProjectListDto { - fn from(out: ListProjectsOutput) -> Self { - Self(out.projects.into_iter().map(ProjectDto::from).collect()) - } -} - -// --------------------------------------------------------------------------- -// Terminals (L3) -// --------------------------------------------------------------------------- - -use application::{ - CloseTerminalInput, CloseTerminalOutput, OpenTerminalInput, OpenTerminalOutput, - ResizeTerminalInput, WriteToTerminalInput, -}; -use domain::SessionId; - -/// Request DTO for `open_terminal`. -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct OpenTerminalRequestDto { - /// Working directory (typically the project root). - pub cwd: String, - /// Initial terminal height in rows. - pub rows: u16, - /// Initial terminal width in columns. - pub cols: u16, - /// Optional explicit command; defaults to the platform shell when omitted. - #[serde(default)] - pub command: Option, - /// Optional arguments for the command. - #[serde(default)] - pub args: Vec, -} - -impl From for OpenTerminalInput { - fn from(dto: OpenTerminalRequestDto) -> Self { - Self { - cwd: dto.cwd, - rows: dto.rows, - cols: dto.cols, - command: dto.command, - args: dto.args, - node_id: None, - } - } -} - -/// Response DTO for `open_terminal`: the freshly-opened session. -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct TerminalSessionDto { - /// Stable session id (UUID string) — used for write/resize/close + the - /// output channel. - pub session_id: String, - /// Working directory the shell runs in. - pub cwd: String, - /// Current rows. - pub rows: u16, - /// Current cols. - pub cols: u16, - /// Conversation id **assigned** by this launch, when the agent's profile - /// supports session assignment and the hosting cell had none yet (T4b). The - /// front persists it on the leaf (`setCellConversation`) so the next open - /// resumes. `None` for a plain terminal, a resume, or a degraded launch. - #[serde(skip_serializing_if = "Option::is_none")] - pub assigned_conversation_id: Option, - /// **Id de session moteur** (resumable du provider courant) exposé par ce - /// lancement, **distinct** de l'id de paire `assignedConversationId` (ARCHITECTURE - /// §19.7, lot P8a). Le front le range dans le **cache** `engineSessionId` de la - /// cellule (jamais sur `conversationId`). Absent pour un terminal nu, une reprise, - /// ou quand le moteur n'expose encore aucun id. La source de vérité du resumable - /// est `providers.json` (lot P8b). - #[serde(skip_serializing_if = "Option::is_none")] - pub engine_session_id: Option, - /// How the frontend should render the cell hosting this session (§17.6): - /// [`CellKind::Chat`] for a structured AI session (an `AgentChatView` driven by - /// `agent_send`/`reattach_agent_chat`), [`CellKind::Pty`] for a raw terminal - /// (xterm). **Derived**, not a layout field: it follows the presence of a - /// structured session descriptor on the launch output — a single source of - /// truth. Plain terminals and the non-launch construction paths default to - /// [`CellKind::Pty`] (the historical, non-breaking shape: a PTY session DTO - /// always serialises with `cellKind: "pty"`). - pub cell_kind: CellKind, -} - -/// Whether a session's hosting cell renders as a structured chat view or a raw -/// terminal (ARCHITECTURE §17.6). Serialises as `"chat"` / `"pty"` on the wire; -/// the frontend `LayoutGrid` switches `AgentChatView` vs `TerminalView` on it. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum CellKind { - /// A raw PTY terminal cell (xterm) — the historical default. - Pty, - /// A structured AI chat cell (`AgentChatView`), backed by an `AgentSession`. - Chat, -} - -impl From for TerminalSessionDto { - fn from(out: OpenTerminalOutput) -> Self { - let s = out.session; - Self { - session_id: s.id.to_string(), - cwd: s.cwd.as_str().to_owned(), - rows: s.pty_size.rows, - cols: s.pty_size.cols, - assigned_conversation_id: None, - engine_session_id: None, - cell_kind: CellKind::Pty, - } - } -} - -/// Response DTO for `reattach_terminal`: the retained scrollback of a still-live -/// session, repainted into the re-mounting xterm before the new output stream is -/// wired. Bytes are serialised as a number array, matching the PTY output channel. -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct ReattachResultDto { - /// The session that was re-attached (echoed back for the frontend). - pub session_id: String, - /// The most-recent retained output bytes (scrollback ring buffer). - pub scrollback: Vec, -} - -/// Request DTO for `write_terminal`. -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct WriteTerminalRequestDto { - /// Target session id. - pub session_id: String, - /// Bytes to write (xterm keystrokes). - pub data: Vec, -} - -impl WriteTerminalRequestDto { - /// Converts to the use-case input, parsing the session id. - /// - /// # Errors - /// [`ErrorDto`] with code `INVALID` if the id is malformed. - pub fn into_input(self) -> Result { - Ok(WriteToTerminalInput { - session_id: parse_session_id(&self.session_id)?, - data: self.data, - }) - } -} - -/// Request DTO for `resize_terminal`. -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ResizeTerminalRequestDto { - /// Target session id. - pub session_id: String, - /// New rows. - pub rows: u16, - /// New cols. - pub cols: u16, -} - -impl ResizeTerminalRequestDto { - /// Converts to the use-case input, parsing the session id. - /// - /// # Errors - /// [`ErrorDto`] with code `INVALID` if the id is malformed. - pub fn into_input(self) -> Result { - Ok(ResizeTerminalInput { - session_id: parse_session_id(&self.session_id)?, - rows: self.rows, - cols: self.cols, - }) - } -} - -/// Response DTO for `close_terminal`. -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct TerminalClosedDto { - /// Exit code, if the process reported one. - pub code: Option, -} - -impl From for TerminalClosedDto { - fn from(out: CloseTerminalOutput) -> Self { - Self { code: out.code } - } -} - -/// Builds a [`CloseTerminalInput`] from a raw session-id string. -/// -/// # Errors -/// [`ErrorDto`] with code `INVALID` if the id is malformed. -pub fn parse_close_terminal(raw: &str) -> Result { - Ok(CloseTerminalInput { - session_id: parse_session_id(raw)?, - }) -} - -/// Parses a session-id string (UUID) coming from the frontend. -/// -/// # Errors -/// Returns an [`ErrorDto`] with code `INVALID` if the string is not a UUID. -pub fn parse_session_id(raw: &str) -> Result { - uuid::Uuid::parse_str(raw) - .map(SessionId::from_uuid) - .map_err(|_| ErrorDto { - code: "INVALID".to_owned(), - message: format!("invalid session id: {raw}"), - }) -} - -// --------------------------------------------------------------------------- -// Layout (L4 + #4 management + #3 per-cell agent) -// --------------------------------------------------------------------------- - -use application::{ - CreateLayoutOutput, DeleteLayoutOutput, LayoutInfo, LayoutOperation, ListLayoutsOutput, - LoadLayoutOutput, MutateLayoutOutput, SetActiveLayoutOutput, -}; -use domain::{AgentId, Direction, LayoutId, LayoutTree, NodeId}; - -/// Response DTO carrying a layout tree. -/// -/// [`LayoutTree`] already serialises camelCase + tagged (its enum uses -/// `#[serde(tag = "type", content = "node")]`), so we embed it directly; the -/// TypeScript mirror in `@/domain` matches this shape. -#[derive(Debug, Clone, Serialize)] -#[serde(transparent)] -pub struct LayoutDto(pub LayoutTree); - -impl From for LayoutDto { - fn from(out: LoadLayoutOutput) -> Self { - Self(out.layout) - } -} - -impl From for LayoutDto { - fn from(out: MutateLayoutOutput) -> Self { - Self(out.layout) - } -} - -/// A layout operation as sent by the frontend (tagged on `type`, camelCase). -/// -/// Mirrors [`LayoutOperation`]; node/session ids cross the wire as UUID strings -/// and are parsed here. `direction` reuses the domain [`Direction`] (which -/// already serialises `"row"`/`"column"`). -#[derive(Debug, Clone, Deserialize)] -#[serde(tag = "type", rename_all = "camelCase")] -pub enum LayoutOperationDto { - /// Split a leaf into a two-child split. - #[serde(rename_all = "camelCase")] - Split { - /// Leaf to split. - target: String, - /// Row (columns) or Column (rows). - direction: Direction, - /// Id for the new sibling leaf. - new_leaf: String, - /// Id for the wrapping split container. - container: String, - }, - /// Collapse a split container back to one child. - #[serde(rename_all = "camelCase")] - Merge { - /// Split container to collapse. - container: String, - /// Index of the child to keep. - keep_index: usize, - }, - /// Reassign a split's child weights. - #[serde(rename_all = "camelCase")] - Resize { - /// Split container to resize. - container: String, - /// New weights (one per child). - weights: Vec, - }, - /// Move a session from one leaf to another. - #[serde(rename_all = "camelCase")] - Move { - /// Source leaf. - from: String, - /// Target (empty) leaf. - to: String, - }, - /// Attach/detach a session to/from a leaf. - #[serde(rename_all = "camelCase")] - SetSession { - /// Hosting leaf. - target: String, - /// Session id, or `null` to clear. - #[serde(default)] - session: Option, - }, - /// Attach/detach an agent to/from a leaf (#3 per-cell agent). - #[serde(rename_all = "camelCase")] - SetCellAgent { - /// Hosting leaf. - target: String, - /// Agent id, or `null` to clear. - #[serde(default)] - agent: Option, - }, - /// Record/clear the persistent CLI conversation id on a leaf (T4b). - #[serde(rename_all = "camelCase")] - SetCellConversation { - /// Hosting leaf. - target: String, - /// Conversation id, or `null` to clear. - #[serde(default)] - conversation_id: Option, - }, -} - -impl LayoutOperationDto { - /// Converts to the use-case operation, parsing all ids. - /// - /// # Errors - /// [`ErrorDto`] with code `INVALID` if any id is malformed. - pub fn into_operation(self) -> Result { - Ok(match self { - Self::Split { - target, - direction, - new_leaf, - container, - } => LayoutOperation::Split { - target: parse_node_id(&target)?, - direction, - new_leaf: parse_node_id(&new_leaf)?, - container: parse_node_id(&container)?, - }, - Self::Merge { - container, - keep_index, - } => LayoutOperation::Merge { - container: parse_node_id(&container)?, - keep_index, - }, - Self::Resize { container, weights } => LayoutOperation::Resize { - container: parse_node_id(&container)?, - weights, - }, - Self::Move { from, to } => LayoutOperation::Move { - from: parse_node_id(&from)?, - to: parse_node_id(&to)?, - }, - Self::SetSession { target, session } => LayoutOperation::SetSession { - target: parse_node_id(&target)?, - session: session.as_deref().map(parse_session_id).transpose()?, - }, - Self::SetCellAgent { target, agent } => LayoutOperation::SetCellAgent { - target: parse_node_id(&target)?, - agent: agent.as_deref().map(parse_agent_id).transpose()?, - }, - Self::SetCellConversation { - target, - conversation_id, - } => LayoutOperation::SetCellConversation { - target: parse_node_id(&target)?, - conversation_id, - }, - }) - } -} - -/// Parses a node-id string (UUID) coming from the frontend. -/// -/// # Errors -/// Returns an [`ErrorDto`] with code `INVALID` if the string is not a UUID. -pub fn parse_node_id(raw: &str) -> Result { - uuid::Uuid::parse_str(raw) - .map(NodeId::from_uuid) - .map_err(|_| ErrorDto { - code: "INVALID".to_owned(), - message: format!("invalid node id: {raw}"), - }) -} - -/// Parses a layout-id string (UUID) coming from the frontend. -/// -/// # Errors -/// Returns an [`ErrorDto`] with code `INVALID` if the string is not a UUID. -pub fn parse_layout_id(raw: &str) -> Result { - uuid::Uuid::parse_str(raw) - .map(LayoutId::from_uuid) - .map_err(|_| ErrorDto { - code: "INVALID".to_owned(), - message: format!("invalid layout id: {raw}"), - }) -} - -// --------------------------------------------------------------------------- -// Layouts (#4) — management DTOs -// --------------------------------------------------------------------------- - -/// Lightweight layout descriptor (id + name + kind), for the tab bar. -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct LayoutInfoDto { - /// Stable layout id (UUID string). - pub id: String, - /// Display name. - pub name: String, - /// Layout kind: `"terminal"` or `"gitGraph"`. - pub kind: String, -} - -impl From for LayoutInfoDto { - fn from(info: LayoutInfo) -> Self { - let kind = match info.kind { - LayoutKind::Terminal => "terminal", - LayoutKind::GitGraph => "gitGraph", - } - .to_owned(); - Self { - id: info.id.to_string(), - name: info.name, - kind, - } - } -} - -/// Response DTO for `list_layouts`. -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct ListLayoutsDto { - /// All named layouts (id + name), in order. - pub layouts: Vec, - /// The id of the currently active layout. - pub active_id: String, -} - -impl From for ListLayoutsDto { - fn from(out: ListLayoutsOutput) -> Self { - Self { - layouts: out.layouts.into_iter().map(LayoutInfoDto::from).collect(), - active_id: out.active_id.to_string(), - } - } -} - -/// Response DTO for `create_layout`. -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct CreateLayoutResultDto { - /// The id minted for the new layout. - pub layout_id: String, -} - -impl From for CreateLayoutResultDto { - fn from(out: CreateLayoutOutput) -> Self { - Self { - layout_id: out.layout_id.to_string(), - } - } -} - -/// Response DTO for `delete_layout`. -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct DeleteLayoutResultDto { - /// The active layout after the deletion. - pub active_id: String, -} - -impl From for DeleteLayoutResultDto { - fn from(out: DeleteLayoutOutput) -> Self { - Self { - active_id: out.active_id.to_string(), - } - } -} - -/// Request DTO for `create_layout`. -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct CreateLayoutRequestDto { - /// Owning project id. - pub project_id: String, - /// Display name for the new layout. - pub name: String, - /// Optional layout kind: `"terminal"` (default) or `"gitGraph"`. - #[serde(default)] - pub kind: Option, -} - -impl CreateLayoutRequestDto { - /// Parses the optional `kind` string into a [`LayoutKind`]. - /// - /// # Errors - /// [`ErrorDto`] with code `INVALID` if the value is not a known kind. - pub fn parse_kind(&self) -> Result { - match self.kind.as_deref() { - None | Some("terminal") => Ok(LayoutKind::Terminal), - Some("gitGraph") => Ok(LayoutKind::GitGraph), - Some(other) => Err(ErrorDto { - code: "INVALID".to_owned(), - message: format!("unknown layout kind: {other}"), - }), - } - } -} - -/// Request DTO for `rename_layout`. -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct RenameLayoutRequestDto { - /// Owning project id. - pub project_id: String, - /// Layout to rename. - pub layout_id: String, - /// New display name. - pub name: String, -} - -/// Request DTO for `delete_layout`. -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct DeleteLayoutRequestDto { - /// Owning project id. - pub project_id: String, - /// Layout to delete. - pub layout_id: String, -} - -/// Response DTO for `set_active_layout`. -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct SetActiveLayoutResultDto { - /// The layout actually activated — equals the requested id when valid, else - /// the unchanged current active id (self-healing fallback). Authoritative. - pub active_id: String, -} - -impl From for SetActiveLayoutResultDto { - fn from(out: SetActiveLayoutOutput) -> Self { - Self { - active_id: out.active_id.to_string(), - } - } -} - -/// Request DTO for `set_active_layout`. -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SetActiveLayoutRequestDto { - /// Owning project id. - pub project_id: String, - /// Layout to make active. - pub layout_id: String, -} - -// --------------------------------------------------------------------------- -// Profiles & first-run (L5) -// --------------------------------------------------------------------------- - -use application::{ - CloneOpenCodeProfileFromSeedInput, CloneOpenCodeProfileFromSeedOutput, ConfigureProfilesInput, - ConfigureProfilesOutput, DeleteProfileInput, DetectProfilesInput, DetectProfilesOutput, - FirstRunStateOutput, ListProfilesOutput, ProfileAvailability, ReferenceProfilesOutput, - SaveProfileInput, SaveProfileOutput, -}; -use domain::profile::{AgentProfile, OpenCodeConfig}; -use domain::ProfileId; - -/// A profile crossing the wire. [`AgentProfile`] already serialises camelCase -/// (id, name, command, args, `contextInjection{strategy,…}`, detect, -/// `cwdTemplate`), so we embed it directly — the TS mirror matches this shape. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(transparent)] -pub struct ProfileDto(pub AgentProfile); - -/// A list of profiles (camelCase array on the wire). -#[derive(Debug, Clone, Serialize)] -#[serde(transparent)] -pub struct ProfileListDto(pub Vec); - -impl From> for ProfileListDto { - fn from(v: Vec) -> Self { - Self(v.into_iter().map(ProfileDto).collect()) - } -} - -impl From for ProfileListDto { - fn from(out: ListProfilesOutput) -> Self { - out.profiles.into() - } -} - -impl From for ProfileListDto { - fn from(out: ReferenceProfilesOutput) -> Self { - out.profiles.into() - } -} - -impl From for ProfileDto { - fn from(out: SaveProfileOutput) -> Self { - Self(out.profile) - } -} - -impl From for ProfileDto { - fn from(out: CloneOpenCodeProfileFromSeedOutput) -> Self { - Self(out.profile) - } -} - -/// Request DTO for `detect_profiles`: the candidate profiles to probe. -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct DetectProfilesRequestDto { - /// Candidate profiles whose `detect` command should be run. - pub candidates: Vec, -} - -impl From for DetectProfilesInput { - fn from(dto: DetectProfilesRequestDto) -> Self { - Self { - candidates: dto.candidates, - } - } -} - -/// One availability result (`profile` + whether its CLI is installed). -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct ProfileAvailabilityDto { - /// The probed profile. - pub profile: AgentProfile, - /// Whether the CLI was detected (exit code 0). - pub available: bool, -} - -impl From for ProfileAvailabilityDto { - fn from(a: ProfileAvailability) -> Self { - Self { - profile: a.profile, - available: a.available, - } - } -} - -/// Response DTO for `detect_profiles`. -#[derive(Debug, Clone, Serialize)] -#[serde(transparent)] -pub struct DetectProfilesResponseDto(pub Vec); - -impl From for DetectProfilesResponseDto { - fn from(out: DetectProfilesOutput) -> Self { - Self(out.results.into_iter().map(Into::into).collect()) - } -} - -/// Request DTO for `save_profile`. -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SaveProfileRequestDto { - /// The profile to upsert. - pub profile: AgentProfile, -} - -impl From for SaveProfileInput { - fn from(dto: SaveProfileRequestDto) -> Self { - Self { - profile: dto.profile, - } - } -} - -/// Request DTO for `clone_opencode_profile_from_seed`. -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct CloneOpenCodeProfileFromSeedRequestDto { - /// Optional display name for the new profile. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub name: Option, - /// Optional OpenCode config override. When omitted, the seed config is copied. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub opencode: Option, -} - -impl From for CloneOpenCodeProfileFromSeedInput { - fn from(dto: CloneOpenCodeProfileFromSeedRequestDto) -> Self { - Self { - name: dto.name, - opencode: dto.opencode, - } - } -} - -/// Request DTO for `configure_profiles` (closes the first run). -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ConfigureProfilesRequestDto { - /// All profiles the user chose to keep. - pub profiles: Vec, -} - -impl From for ConfigureProfilesInput { - fn from(dto: ConfigureProfilesRequestDto) -> Self { - Self { - profiles: dto.profiles, - } - } -} - -impl From for ProfileListDto { - fn from(out: ConfigureProfilesOutput) -> Self { - out.profiles.into() - } -} - -/// Response DTO for `first_run_state`. -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct FirstRunStateDto { - /// `true` when the first-run wizard should be shown. - pub is_first_run: bool, - /// Pre-filled reference catalogue to seed the wizard. - pub reference_profiles: Vec, -} - -impl From for FirstRunStateDto { - fn from(out: FirstRunStateOutput) -> Self { - Self { - is_first_run: out.is_first_run, - reference_profiles: out.reference_profiles, - } - } -} - -// --------------------------------------------------------------------------- -// Local model servers (B35) -// --------------------------------------------------------------------------- - -use application::{ListModelServersOutput, SaveModelServerInput, SaveModelServerOutput}; -use domain::model_server::{ - ExecutablePath, HfModelRef, LlamaCppOptions, LocalModelRef, LocalModelServerConfig, - LocalModelServerKind, ModelPath, ModelServerEndpoint, ModelSource, -}; -use domain::{LocalModelServerId, StopPolicy}; - -/// Local model-server implementation on the IPC wire. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum ModelServerKindDto { - /// llama.cpp `llama-server`. - LlamaCpp, -} - -impl From for ModelServerKindDto { - fn from(kind: LocalModelServerKind) -> Self { - match kind { - LocalModelServerKind::LlamaCpp => Self::LlamaCpp, - } - } -} - -impl From for LocalModelServerKind { - fn from(kind: ModelServerKindDto) -> Self { - match kind { - ModelServerKindDto::LlamaCpp => Self::LlamaCpp, - } - } -} - -/// Stop policy on the IPC wire. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum StopPolicyDto { - /// Keep the process alive. - KeepAlive, - /// Stop when no longer used. - StopWhenUnused, - /// Stop when the application exits. - StopOnAppExit, -} - -impl From for StopPolicyDto { - fn from(policy: StopPolicy) -> Self { - match policy { - StopPolicy::KeepAlive => Self::KeepAlive, - StopPolicy::StopWhenUnused => Self::StopWhenUnused, - StopPolicy::StopOnAppExit => Self::StopOnAppExit, - } - } -} - -impl From for StopPolicy { - fn from(policy: StopPolicyDto) -> Self { - match policy { - StopPolicyDto::KeepAlive => Self::KeepAlive, - StopPolicyDto::StopWhenUnused => Self::StopWhenUnused, - StopPolicyDto::StopOnAppExit => Self::StopOnAppExit, - } - } -} - -/// Model source on the IPC wire. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", tag = "type")] -pub enum ModelSourceDto { - /// Local `.gguf` path. - LocalPath { - /// Absolute local path. - path: String, - }, - /// Hugging Face `namespace/repo[:quant]` reference. - HuggingFace { - /// Repository reference. - repo: String, - }, -} - -impl From for ModelSourceDto { - fn from(source: ModelSource) -> Self { - match source { - ModelSource::LocalPath { path } => Self::LocalPath { path: path.0 }, - ModelSource::HuggingFace { repo } => Self::HuggingFace { repo: repo.0 }, - } - } -} - -impl ModelSourceDto { - fn into_domain(self) -> Result { - match self { - Self::LocalPath { path } => Ok(ModelSource::LocalPath { - path: ModelPath::new(path).map_err(invalid_domain_error)?, - }), - Self::HuggingFace { repo } => Ok(ModelSource::HuggingFace { - repo: HfModelRef::new(repo).map_err(invalid_domain_error)?, - }), - } - } -} - -/// A local model-server config crossing the wire. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ModelServerConfigDto { - /// Stable local server config id. - pub id: String, - /// Server implementation kind. - pub kind: ModelServerKindDto, - /// Display name. - pub name: String, - /// OpenAI-compatible base URL. - #[serde(rename = "baseURL")] - pub base_url: String, - /// TCP port. - pub port: u16, - /// Optional explicit model source. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub model_source: Option, - /// Legacy V1 input alias for `modelSource:{type:"localPath",path}`. - #[serde(default, skip_serializing)] - pub model_path: Option, - /// Served model name exposed to OpenCode. - pub served_model_name: String, - /// Optional explicit `llama-server` executable path. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub binary_path: Option, - /// llama.cpp host passed to `--host`. - #[serde(default = "default_llamacpp_host")] - pub host: String, - /// llama.cpp GPU layers passed to `-ngl`. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub gpu_layers: Option, - /// llama.cpp context size passed to `-c`. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub context_size: Option, - /// Whether to pass `--jinja`. - #[serde(default)] - pub jinja: bool, - /// Extra argv entries. - #[serde(default)] - pub args: Vec, - /// Whether IdeA should start the server lazily. - pub auto_start: bool, - /// Stop policy. - pub stop_policy: StopPolicyDto, - /// Optional readiness warmup deadline override in seconds. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub warmup_deadline_secs: Option, -} - -impl ModelServerConfigDto { - /// Maps a domain config to the flat IPC DTO. - #[must_use] - pub fn from_domain(config: LocalModelServerConfig) -> Self { - Self { - id: config.id.to_string(), - kind: config.kind.into(), - name: config.name, - base_url: config.endpoint.base_url, - port: config.endpoint.port, - model_source: config.model.source.map(Into::into), - model_path: None, - served_model_name: config.model.served_name, - binary_path: config.binary.map(|binary| binary.as_str().to_owned()), - host: config.options.host, - gpu_layers: config.options.gpu_layers, - context_size: config.options.context_size, - jinja: config.options.jinja, - args: config.args, - auto_start: config.auto_start, - stop_policy: config.stop_policy.into(), - warmup_deadline_secs: config.warmup_deadline_secs, - } - } - - /// Maps the flat IPC DTO into the domain config, preserving internal model id - /// from the existing config when available. - /// - /// # Errors - /// [`ErrorDto`] with `INVALID` code if any domain invariant rejects the input. - pub fn into_domain( - self, - existing: Option<&LocalModelServerConfig>, - ) -> Result { - let server_id = parse_model_server_id(&self.id)?; - let endpoint = ModelServerEndpoint::new(self.base_url.clone(), self.port) - .map_err(invalid_domain_error)?; - let source = resolve_model_source(self.model_source, self.model_path)?; - let model_id = existing - .filter(|config| config.id == server_id) - .map(|config| config.model.id.clone()) - .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()); - let label = non_empty_or_fallback(&self.served_model_name, &self.name); - let model = LocalModelRef::new(model_id, label, source, self.served_model_name.clone()) - .map_err(invalid_domain_error)?; - let binary = optional_non_empty(self.binary_path) - .map(ExecutablePath::new) - .transpose() - .map_err(invalid_domain_error)?; - let options = - LlamaCppOptions::new(self.host, self.gpu_layers, self.context_size, self.jinja) - .map_err(invalid_domain_error)?; - LocalModelServerConfig::new( - server_id, - self.kind.into(), - self.name, - endpoint, - model, - binary, - options, - self.args, - self.auto_start, - self.stop_policy.into(), - ) - .and_then(|config| config.with_warmup_deadline_secs(self.warmup_deadline_secs)) - .map_err(invalid_domain_error) - } -} - -/// Response DTO for `preview_model_server_command`. -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct PreviewModelServerCommandDto { - /// Executable command. - pub command: String, - /// Arguments without shell parsing. - pub args: Vec, - /// Human-readable escaped command line. - pub display: String, -} - -/// List response for local model servers. -#[derive(Debug, Clone, Serialize)] -#[serde(transparent)] -pub struct ModelServerConfigListDto(pub Vec); - -impl From for ModelServerConfigListDto { - fn from(out: ListModelServersOutput) -> Self { - Self( - out.servers - .into_iter() - .map(ModelServerConfigDto::from_domain) - .collect(), - ) - } -} - -/// Request DTO for `save_model_server`. -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SaveModelServerRequestDto { - /// Config to upsert. - pub config: ModelServerConfigDto, -} - -impl From for ModelServerConfigDto { - fn from(out: SaveModelServerOutput) -> Self { - Self::from_domain(out.config) - } -} - -/// Parses a local model-server id string. -/// -/// # Errors -/// [`ErrorDto`] with `INVALID` code if the string is not a UUID. -pub fn parse_model_server_id(raw: &str) -> Result { - uuid::Uuid::parse_str(raw) - .map(LocalModelServerId::from_uuid) - .map_err(|_| ErrorDto { - code: "INVALID".to_owned(), - message: format!("invalid model server id: {raw}"), - }) -} - -/// Builds a save-model-server input after the caller has resolved the existing -/// config, if any. -/// -/// # Errors -/// [`ErrorDto`] if DTO-to-domain validation fails. -pub fn save_model_server_input( - request: SaveModelServerRequestDto, - existing: Option<&LocalModelServerConfig>, -) -> Result { - Ok(SaveModelServerInput { - config: request.config.into_domain(existing)?, - }) -} - -/// Converts a model-server DTO to domain using the same path as save. -/// -/// # Errors -/// [`ErrorDto`] if DTO-to-domain validation fails. -pub fn model_server_config_domain( - config: ModelServerConfigDto, - existing: Option<&LocalModelServerConfig>, -) -> Result { - config.into_domain(existing) -} - -fn resolve_model_source( - model_source: Option, - model_path: Option, -) -> Result, ErrorDto> { - let legacy_path = optional_non_empty(model_path); - match (model_source, legacy_path) { - (None, None) => Ok(None), - (None, Some(path)) => Ok(Some(ModelSource::LocalPath { - path: ModelPath::new(path).map_err(invalid_domain_error)?, - })), - (Some(source), None) => Ok(Some(source.into_domain()?)), - (Some(source), Some(path)) => { - let domain_source = source.into_domain()?; - match &domain_source { - ModelSource::LocalPath { path: source_path } if source_path.as_str() == path => { - Ok(Some(domain_source)) - } - _ => Err(ErrorDto { - code: "INVALID".to_owned(), - message: "modelPath and modelSource differ".to_owned(), - }), - } - } - } -} - -fn optional_non_empty(raw: Option) -> Option { - raw.map(|value| value.trim().to_owned()) - .filter(|value| !value.is_empty()) -} - -fn default_llamacpp_host() -> String { - "127.0.0.1".to_owned() -} - -fn non_empty_or_fallback(primary: &str, fallback: &str) -> String { - let primary = primary.trim(); - if primary.is_empty() { - fallback.trim().to_owned() - } else { - primary.to_owned() - } -} - -fn invalid_domain_error(error: impl std::fmt::Display) -> ErrorDto { - ErrorDto { - code: "INVALID".to_owned(), - message: error.to_string(), - } -} - -// --------------------------------------------------------------------------- -// Embedder profiles & engines (LOT C2 — §14.5.3) -// --------------------------------------------------------------------------- - -use application::{ - DismissChoice, DismissEmbedderSuggestionInput, EmbedderEnginesView, ListEmbedderProfilesOutput, - OnnxModelView, SaveEmbedderProfileInput, SaveEmbedderProfileOutput, -}; -use domain::profile::EmbedderProfile; - -/// An embedder profile crossing the wire. [`EmbedderProfile`] already serialises -/// camelCase (`id, name, strategy, model?, endpoint?, apiKeyEnv?, dimension`), so we -/// embed it directly — the TS mirror matches this shape. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(transparent)] -pub struct EmbedderProfileDto(pub EmbedderProfile); - -/// A list of embedder profiles (camelCase array on the wire). -#[derive(Debug, Clone, Serialize)] -#[serde(transparent)] -pub struct EmbedderProfileListDto(pub Vec); - -impl From> for EmbedderProfileListDto { - fn from(v: Vec) -> Self { - Self(v.into_iter().map(EmbedderProfileDto).collect()) - } -} - -impl From for EmbedderProfileListDto { - fn from(out: ListEmbedderProfilesOutput) -> Self { - out.profiles.into() - } -} - -impl From for EmbedderProfileDto { - fn from(out: SaveEmbedderProfileOutput) -> Self { - Self(out.profile) - } -} - -/// Request DTO for `save_embedder_profile`: the embedder profile to upsert. -/// -/// Carries the [`EmbedderProfile`] fields directly (the entity validates them in the -/// use case). Deserialised camelCase to match the persisted/domain shape. -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SaveEmbedderProfileRequestDto { - /// The embedder profile to upsert. - pub profile: EmbedderProfile, -} - -impl From for SaveEmbedderProfileInput { - fn from(dto: SaveEmbedderProfileRequestDto) -> Self { - let EmbedderProfile { - id, - name, - strategy, - model, - endpoint, - api_key_env, - dimension, - } = dto.profile; - Self { - id, - name, - strategy, - model, - endpoint, - api_key_env, - dimension, - } - } -} - -/// One recommendable local ONNX model on the wire (camelCase). -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct OnnxModelInfoDto { - /// Stable model id accepted by a `localOnnx` profile's `model` field. - pub id: String, - /// Human-readable name for the UI. - pub display_name: String, - /// Length of the vectors this model produces. - pub dimension: usize, - /// Approximate download/disk size in megabytes. - pub approx_size_mb: u32, - /// Whether this is the recommended default model. - pub recommended: bool, -} - -impl From for OnnxModelInfoDto { - fn from(m: OnnxModelView) -> Self { - Self { - id: m.id, - display_name: m.display_name, - dimension: m.dimension, - approx_size_mb: m.approx_size_mb, - recommended: m.recommended, - } - } -} - -/// Response DTO for `describe_embedder_engines` (drives the "configure an embedder?" -/// UI). All-camelCase wire shape. -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct EmbedderEnginesDto { - /// The curated catalogue of recommendable local ONNX models. - pub recommended_onnx: Vec, - /// Whether an Ollama-style local embedding server was detected (best-effort). - pub ollama_detected: bool, - /// Ids of the recommended ONNX models already present in the local cache. - pub onnx_cached_models: Vec, - /// Whether the HTTP capability (`localServer`/`api`) is compiled into this binary. - pub vector_http_enabled: bool, - /// Whether the in-process ONNX capability (`localOnnx`) is compiled into this binary. - pub vector_onnx_enabled: bool, -} - -impl From for EmbedderEnginesDto { - fn from(v: EmbedderEnginesView) -> Self { - Self { - recommended_onnx: v.recommended_onnx.into_iter().map(Into::into).collect(), - ollama_detected: v.ollama_detected, - onnx_cached_models: v.onnx_cached_models, - vector_http_enabled: v.vector_http_enabled, - vector_onnx_enabled: v.vector_onnx_enabled, - } - } -} - -// --------------------------------------------------------------------------- -// Embedder suggestion (LOT C3 — §14.5.5) -// --------------------------------------------------------------------------- - -/// The user's response to the embedder suggestion, on the wire (camelCase: -/// `"later"` | `"never"`). -#[derive(Debug, Clone, Copy, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum DismissChoiceDto { - /// "Plus tard" — re-proposable next session. - Later, - /// "Ne plus demander" — never again. - Never, -} - -impl From for DismissChoice { - fn from(c: DismissChoiceDto) -> Self { - match c { - DismissChoiceDto::Later => Self::Later, - DismissChoiceDto::Never => Self::Never, - } - } -} - -/// Request DTO for `dismiss_embedder_suggestion`. The `project_id` is resolved to a -/// project root by the command; `choice` is the user's dismissal. -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct DismissEmbedderSuggestionRequestDto { - /// The project the suggestion concerned (UUID string). - pub project_id: String, - /// The user's choice. - pub choice: DismissChoiceDto, -} - -impl DismissEmbedderSuggestionRequestDto { - /// Builds the use-case input from a resolved project root + the DTO choice. - #[must_use] - pub fn into_input(self, project_root: domain::ProjectPath) -> DismissEmbedderSuggestionInput { - DismissEmbedderSuggestionInput { - project_root, - choice: self.choice.into(), - } - } -} - -/// Builds a [`DeleteProfileInput`] from a raw profile-id string. -/// -/// # Errors -/// [`ErrorDto`] with code `INVALID` if the id is malformed. -pub fn parse_delete_profile(raw: &str) -> Result { - Ok(DeleteProfileInput { - id: parse_profile_id(raw)?, - }) -} - -/// Parses a profile-id string (UUID) coming from the frontend. -/// -/// # Errors -/// Returns an [`ErrorDto`] with code `INVALID` if the string is not a UUID. -pub fn parse_profile_id(raw: &str) -> Result { - uuid::Uuid::parse_str(raw) - .map(ProfileId::from_uuid) - .map_err(|_| ErrorDto { - code: "INVALID".to_owned(), - message: format!("invalid profile id: {raw}"), - }) -} - -// --------------------------------------------------------------------------- -// Agents (L6) -// --------------------------------------------------------------------------- - -use application::{ - ChangeAgentProfileOutput, CreateAgentOutput, InspectConversationOutput, LaunchAgentOutput, - ListAgentsOutput, ReadAgentContextOutput, -}; -use domain::{Agent, EffectivePermissions, PermissionSet, ProjectPermissions, TerminalSession}; - -/// An agent crossing the wire. [`Agent`] already serialises camelCase -/// (`id`, `name`, `contextPath`, `profileId`, `origin` tagged, `synchronized`), -/// so we embed it directly — the TS mirror matches this shape. -#[derive(Debug, Clone, Serialize)] -#[serde(transparent)] -pub struct AgentDto(pub Agent); - -/// A list of agents (camelCase array on the wire). -#[derive(Debug, Clone, Serialize)] -#[serde(transparent)] -pub struct AgentListDto(pub Vec); - -impl From for AgentListDto { - fn from(out: ListAgentsOutput) -> Self { - Self(out.agents.into_iter().map(AgentDto).collect()) - } -} - -impl From for AgentDto { - fn from(out: CreateAgentOutput) -> Self { - Self(out.agent) - } -} - -/// Request DTO for `create_agent`. -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct CreateAgentRequestDto { - /// Id of the owning project. - pub project_id: String, - /// Display name for the new agent. - pub name: String, - /// Runtime profile id. - pub profile_id: String, - /// Initial Markdown content (empty when absent). - #[serde(default)] - pub initial_content: Option, -} - -/// Response DTO for `read_agent_context`. -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct ReadAgentContextResponseDto { - /// The agent's Markdown context content. - pub content: String, -} - -impl From for ReadAgentContextResponseDto { - fn from(out: ReadAgentContextOutput) -> Self { - Self { - content: out.content.into_string(), - } - } -} - -/// Request DTO for `update_agent_context`. -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct UpdateAgentContextRequestDto { - /// Id of the owning project. - pub project_id: String, - /// Id of the agent to update. - pub agent_id: String, - /// New Markdown content. - pub content: String, -} - -// --------------------------------------------------------------------------- -// Permissions (LP1) -// --------------------------------------------------------------------------- - -/// Full project permission document crossing the wire. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(transparent)] -pub struct ProjectPermissionsDto(pub ProjectPermissions); - -/// Effective permissions crossing the wire. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(transparent)] -pub struct EffectivePermissionsDto(pub EffectivePermissions); - -/// Request DTO for updating project default permissions. -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct UpdateProjectPermissionsRequestDto { - /// Id of the owning project. - pub project_id: String, - /// New project defaults. `null` removes defaults. - pub permissions: Option, -} - -/// Request DTO for updating one agent permission override. -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct UpdateAgentPermissionsRequestDto { - /// Id of the owning project. - pub project_id: String, - /// Target agent id. - pub agent_id: String, - /// New override. `null` removes the override. - pub permissions: Option, -} - -/// Request DTO for resolving one agent's effective permissions. -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ResolveAgentPermissionsRequestDto { - /// Id of the owning project. - pub project_id: String, - /// Target agent id. - pub agent_id: String, -} - -/// Request DTO for `update_project_context`. -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct UpdateProjectContextRequestDto { - /// Id of the owning project. - pub project_id: String, - /// New project-level Markdown context, stored under `.ideai/CONTEXT.md`. - pub content: String, -} - -/// Request DTO for `launch_agent`. -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct LaunchAgentRequestDto { - /// Id of the owning project. - pub project_id: String, - /// Id of the agent to launch. - pub agent_id: String, - /// Initial terminal height in rows. - pub rows: u16, - /// Initial terminal width in columns. - pub cols: u16, - /// Persistent CLI conversation id currently recorded on the hosting cell, if - /// any. `Some` ⇒ the launch resumes it; absent/`None` ⇒ a fresh cell (the - /// launch may assign a new id when the profile supports it). - #[serde(default)] - pub conversation_id: Option, - /// The layout leaf (node) hosting this launch. Enforces the "one live session - /// per agent" invariant: a launch into a node different from where the agent - /// is already running is refused (`AGENT_ALREADY_RUNNING`); the same node is - /// idempotent. Absent ⇒ a fresh node is minted (and any already-live agent is - /// refused). - #[serde(default)] - pub node_id: Option, -} - -impl From for TerminalSessionDto { - fn from(out: LaunchAgentOutput) -> Self { - let assigned_conversation_id = out.assigned_conversation_id; - let engine_session_id = out.engine_session_id; - // §17.6: the cell kind is *derived* from the launch routing. A structured - // descriptor (`structured: Some(..)`) means LaunchAgent routed to an - // AgentSession ⇒ chat cell; its absence means the PTY/terminal path ⇒ - // terminal cell. Single source of truth, no layout migration. - let cell_kind = if out.structured.is_some() { - CellKind::Chat - } else { - CellKind::Pty - }; - let s = out.session; - Self { - session_id: s.id.to_string(), - cwd: s.cwd.as_str().to_owned(), - rows: s.pty_size.rows, - cols: s.pty_size.cols, - assigned_conversation_id, - engine_session_id, - cell_kind, - } - } -} - -/// Request DTO for `interrupt_agent` (cadrage C4 §4.2): the Interrompre path. The -/// frontend sends `{ request: { projectId, agentId } }`. -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct InterruptAgentRequestDto { - /// Id of the owning project. - pub project_id: String, - /// Id of the agent whose running turn to preempt. - pub agent_id: String, -} - -/// Request DTO for `delegation_delivered` (ARCHITECTURE §20.3): the frontend write- -/// portal acks that it **physically wrote** a delegation `ticket` into the agent's -/// native PTY. Best-effort observability — it never changes correlation (the `ask` is -/// still woken by `idea_reply`). The frontend sends `{ request: { projectId, agentId, -/// ticket } }`. -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct DeliveredDelegationRequestDto { - /// Id of the owning project. - pub project_id: String, - /// Id of the agent whose terminal received the delegation. - pub agent_id: String, - /// Id of the delivered mailbox ticket. - pub ticket: String, -} - -/// Request DTO for `set_front_attached`: the write-portal of an agent cell reports -/// whether a **frontend terminal cell is mounted** for `agentId` (`true` on mount, -/// `false` on unmount). The mediator uses it to choose, at delivery time, between -/// publishing `DelegationReady` (a cell will write it) and writing the turn into the -/// PTY itself (headless/background-delegated agent with no cell). The frontend sends -/// `{ request: { agentId, attached } }`. -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct FrontAttachedRequestDto { - /// Id of the agent whose terminal cell mounted/unmounted. - pub agent_id: String, - /// `true` when the cell's write-portal is now active, `false` on teardown. - pub attached: bool, -} - -/// Request DTO for `change_agent_profile` (§15.1): hot-swap an agent's runtime -/// profile, optionally relaunching its live session in place. -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ChangeAgentProfileRequestDto { - /// Id of the owning project. - pub project_id: String, - /// Id of the agent whose runtime profile to change. - pub agent_id: String, - /// Id of the new runtime profile. - pub profile_id: String, - /// Terminal height in rows for a possible hot relaunch. - pub rows: u16, - /// Terminal width in columns for a possible hot relaunch. - pub cols: u16, -} - -/// Response DTO for `change_agent_profile`: the mutated agent plus the freshly -/// relaunched session when a live session was hot-swapped (absent otherwise). -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct ChangeAgentProfileDto { - /// The agent now carrying the new profile. - pub agent: AgentDto, - /// The relaunched session, present only when a live session was swapped. - #[serde(skip_serializing_if = "Option::is_none")] - pub relaunched_session: Option, -} - -impl From for ChangeAgentProfileDto { - fn from(out: ChangeAgentProfileOutput) -> Self { - Self { - agent: AgentDto(out.agent), - relaunched_session: out.relaunched.map(TerminalSessionDto::from), - } - } -} - -impl From for TerminalSessionDto { - fn from(s: TerminalSession) -> Self { - Self { - session_id: s.id.to_string(), - cwd: s.cwd.as_str().to_owned(), - rows: s.pty_size.rows, - cols: s.pty_size.cols, - assigned_conversation_id: None, - engine_session_id: None, - cell_kind: CellKind::Pty, - } - } -} - -// --------------------------------------------------------------------------- -// Structured chat sessions (§17 — D4) -// --------------------------------------------------------------------------- - -/// One incremental chunk of a structured agent reply, streamed over the chat -/// session's [`tauri::ipc::Channel`] (ARCHITECTURE §17.7). The serialised wire -/// twin of a [`domain::ports::ReplyEvent`]: the `agent_send` pump maps each turn -/// event to one of these and pushes it to the frontend `AgentChatView`. -/// -/// Tagged on `kind` (camelCase: `"textDelta"` | `"toolActivity"` | `"final"` | -/// `"error"`), so the front branches without positional parsing. `Final` is the -/// normal deterministic terminal chunk of a turn; `Error` is a visible terminal -/// fallback for an otherwise silent/empty turn. `Deserialize` is derived too so -/// tests (and a mock gateway) can round-trip it. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(tag = "kind", rename_all = "camelCase")] -pub enum ReplyChunk { - /// An assistant text fragment (incremental chat rendering). - #[serde(rename_all = "camelCase")] - TextDelta { - /// The text fragment. - text: String, - }, - /// A human-readable tool-activity badge (best-effort observability). - #[serde(rename_all = "camelCase")] - ToolActivity { - /// The human-readable activity label. - label: String, - }, - /// The deterministic end-of-turn chunk carrying the aggregated final content. - #[serde(rename_all = "camelCase")] - Final { - /// The aggregated final content of the turn. - content: String, - }, - /// A visible terminal fallback when the model stream produced no usable answer. - #[serde(rename_all = "camelCase")] - Error { - /// Human-readable explanation to render in the chat cell. - message: String, - }, -} - -/// Response DTO for `reattach_agent_chat`: the retained **conversation -/// scrollback** of a still-live structured session, replayed into the -/// re-mounting `AgentChatView` before the new reply stream is wired (§17.6). The -/// typed twin of [`ReattachResultDto`] (PTY scrollback bytes) — here the -/// scrollback is the ordered list of [`ReplyChunk`]s already streamed. -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct ReattachChatDto { - /// The session that was re-attached (echoed back for the frontend). - pub session_id: String, - /// The chunks already streamed for this conversation, in order. The frontend - /// replays them to rebuild the visible turns, then receives subsequent chunks - /// over the freshly-registered channel. - pub scrollback: Vec, -} - -/// Request DTO for `inspect_conversation` (T7). -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct InspectConversationRequestDto { - /// Id of the owning project. - pub project_id: String, - /// Id of the agent whose conversation is inspected. - pub agent_id: String, - /// The conversation id recorded on the hosting cell. - pub conversation_id: String, -} - -/// Response DTO for `inspect_conversation` (T7): the best-effort enriched -/// details for a resume popup. Both fields are optional and **omitted from the -/// wire when `None`** (`skip_serializing_if`), so the TypeScript side sees an -/// absent key — not `null` — for a degraded inspection. -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct ConversationDetailsDto { - /// A short, best-effort label for the conversation (last user message, - /// truncated). Absent when it could not be extracted. - #[serde(skip_serializing_if = "Option::is_none")] - pub last_topic: Option, - /// A best-effort cumulative token count. Absent when no usage info exists. - #[serde(skip_serializing_if = "Option::is_none")] - pub token_count: Option, -} - -impl From for ConversationDetailsDto { - fn from(out: InspectConversationOutput) -> Self { - Self { - last_topic: out.details.last_topic, - token_count: out.details.token_count, - } - } -} - -/// One currently-live agent and the cell hosting it, for `list_live_agents`. -/// -/// Lets the UI disable an agent already running in another cell (it cannot be -/// launched a second time — the "one live session per agent" invariant). -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct LiveAgentDto { - /// The live agent's id (UUID string). - pub agent_id: String, - /// The hosting layout leaf's node id (UUID string). - pub node_id: String, - /// The live PTY session id, used to reattach a newly-opened cell without - /// respawning the agent. - pub session_id: String, - /// Runtime family that owns the session (`pty`/`structured`). - pub kind: LiveWorkSessionKindDto, -} - -/// Response DTO for `list_live_agents` (transparent array on the wire). -#[derive(Debug, Clone, Serialize)] -#[serde(transparent)] -pub struct LiveAgentListDto(pub Vec); - -impl LiveAgentListDto { - /// Builds the wire list from the registry's live-session snapshots. - /// - /// De-duplicates by `agent_id`: the "one live session per agent" invariant - /// guarantees an agent is live in at most one registry (PTY **or** - /// structured), but the aggregated input could in theory carry the same agent - /// twice; the UI contract is a dup-free set (each agent appears once), so we - /// keep the first occurrence and drop any later one for the same agent. - #[must_use] - pub fn from_snapshots(pairs: Vec) -> Self { - let mut seen = std::collections::HashSet::new(); - Self( - pairs - .into_iter() - .filter(|snapshot| seen.insert(snapshot.agent_id)) - .map(|snapshot| LiveAgentDto { - agent_id: snapshot.agent_id.to_string(), - node_id: snapshot.node_id.to_string(), - session_id: snapshot.session_id.to_string(), - kind: snapshot.kind.into(), - }) - .collect(), - ) - } -} - -/// Runtime family of a live work-state session. -#[derive(Debug, Clone, Copy, Serialize)] -#[serde(rename_all = "camelCase")] -pub enum LiveWorkSessionKindDto { - /// Raw PTY-backed CLI session. - Pty, - /// Structured agent-session backend. - Structured, -} - -impl From for LiveWorkSessionKindDto { - fn from(kind: LiveSessionKind) -> Self { - match kind { - LiveSessionKind::Pty => Self::Pty, - LiveSessionKind::Structured => Self::Structured, - } - } -} - -/// Live session coordinates in the project work-state read model. -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct LiveWorkSessionDto { - /// The layout node currently hosting the session view. - pub node_id: String, - /// The live session id. - pub session_id: String, - /// Runtime family that owns the session. - pub kind: LiveWorkSessionKindDto, -} - -/// Derived processing status of a queued ticket. -#[derive(Debug, Clone, Copy, Serialize)] -#[serde(rename_all = "camelCase")] -pub enum TicketWorkStatusDto { - /// The agent's current busy turn is running this ticket. - InProgress, - /// Waiting behind the head / the agent is idle. - Queued, -} - -impl From for TicketWorkStatusDto { - fn from(status: TicketWorkStatus) -> Self { - match status { - TicketWorkStatus::InProgress => Self::InProgress, - TicketWorkStatus::Queued => Self::Queued, - } - } -} - -/// Origin of a queued ticket (human operator or a delegating agent). -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase", tag = "kind")] -pub enum TicketWorkSourceDto { - /// The human operator. - Human, - /// Another agent delegating via `idea_ask_agent`. - #[serde(rename_all = "camelCase")] - Agent { - /// The delegating agent id. - agent_id: String, - }, -} - -impl From for TicketWorkSourceDto { - fn from(source: TicketWorkSource) -> Self { - match source { - TicketWorkSource::Human => Self::Human, - TicketWorkSource::Agent { agent_id } => Self::Agent { - agent_id: agent_id.to_string(), - }, - } - } -} - -/// One queued/in-progress delegation ticket for an agent. -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct AgentTicketStateDto { - /// Stable id of the queued ticket. - pub ticket_id: String, - /// Conversation thread this task enters. - pub conversation_id: String, - /// FIFO position at snapshot time (`0` = head). - pub position: u32, - /// Derived status (in-progress vs queued). - pub status: TicketWorkStatusDto, - /// Origin of the ticket. - pub source: TicketWorkSourceDto, - /// Display label of the requester. - pub requester_label: String, - /// Bounded excerpt of the task. - pub task_preview: String, - /// Character length of the original (un-truncated) task. - pub task_len: usize, -} - -impl From for AgentTicketStateDto { - fn from(ticket: AgentTicketState) -> Self { - Self { - ticket_id: ticket.ticket_id.to_string(), - conversation_id: ticket.conversation_id.to_string(), - position: ticket.position, - status: ticket.status.into(), - source: ticket.source.into(), - requester_label: ticket.requester_label, - task_preview: ticket.task_preview, - task_len: ticket.task_len, - } - } -} - -/// One background task owned by an agent in the Work panel read model. -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct AgentBackgroundTaskStateDto { - /// Stable task id (UUID string). - pub task_id: String, - /// Kind discriminant. - pub kind: String, - /// Lifecycle state. - pub state: String, - /// Process exit code, when the terminal result carries one. - #[serde(skip_serializing_if = "Option::is_none")] - pub exit_code: Option, - /// Human-readable summary / error / reason of the terminal result. - #[serde(skip_serializing_if = "Option::is_none")] - pub summary: Option, - /// Bounded stdout tail. - #[serde(skip_serializing_if = "Option::is_none")] - pub stdout_tail: Option, - /// Bounded stderr tail. - #[serde(skip_serializing_if = "Option::is_none")] - pub stderr_tail: Option, - /// Creation timestamp, epoch milliseconds. - pub created_at_ms: u64, - /// Last update timestamp, epoch milliseconds. - pub updated_at_ms: u64, -} - -impl From for AgentBackgroundTaskStateDto { - fn from(task: AgentBackgroundTaskState) -> Self { - Self { - task_id: task.task_id.to_string(), - kind: background_kind_label_from_work_state(task.kind).to_owned(), - state: background_state_label(task.state).to_owned(), - exit_code: task.exit_code, - summary: task.summary, - stdout_tail: task.stdout_tail, - stderr_tail: task.stderr_tail, - created_at_ms: task.created_at_ms, - updated_at_ms: task.updated_at_ms, - } - } -} - -/// One manifest agent's current live/busy state. -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct AgentWorkStateDto { - /// Agent id. - pub agent_id: String, - /// Agent display name. - pub name: String, - /// Runtime profile id assigned to the agent. - pub profile_id: String, - /// Live session, if any. - pub live: Option, - /// Current mediated-input busy state. - pub busy: AgentBusyState, - /// Pending/in-progress delegation tickets, in FIFO order. - pub tickets: Vec, - /// Best-effort first-class background tasks owned by this agent. - pub background_tasks: Vec, -} - -/// How much of a [`ConversationWorkSummaryDto`] could be derived, best-effort. -#[derive(Debug, Clone, Copy, Serialize)] -#[serde(rename_all = "camelCase")] -pub enum ConversationPreviewStatusDto { - /// A handoff was present and usable. - Ready, - /// No handoff yet; `recentTurns` may carry the log fallback. - Missing, - /// The handoff was unreadable but the log fallback was readable. - Partial, - /// Neither the handoff nor the log could be read. - Unavailable, -} - -impl From for ConversationPreviewStatusDto { - fn from(status: ConversationPreviewStatus) -> Self { - match status { - ConversationPreviewStatus::Ready => Self::Ready, - ConversationPreviewStatus::Missing => Self::Missing, - ConversationPreviewStatus::Partial => Self::Partial, - ConversationPreviewStatus::Unavailable => Self::Unavailable, - } - } -} - -/// One recent turn surfaced in a conversation summary's log fallback. -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct ConversationTurnWorkPreviewDto { - /// Nature of the turn (`prompt`/`response`/`toolActivity`). - pub role: TurnRole, - /// Origin of the turn (human operator or a delegating agent). - pub source: TicketWorkSourceDto, - /// Timestamp (epoch milliseconds) of the turn. - pub at_ms: u64, - /// Bounded excerpt of the turn text. - pub text_preview: String, - /// Character length of the original (un-truncated) turn text. - pub text_len: usize, -} - -impl From for ConversationTurnWorkPreviewDto { - fn from(turn: ConversationTurnWorkPreview) -> Self { - Self { - role: turn.role, - source: turn.source.into(), - at_ms: turn.at_ms, - text_preview: turn.text_preview, - text_len: turn.text_len, - } - } -} - -/// Best-effort, read-only summary of one conversation visible through the tickets. -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct ConversationWorkSummaryDto { - /// The conversation (pair) this summary describes. - pub conversation_id: String, - /// How much could be derived. - pub status: ConversationPreviewStatusDto, - /// Bounded excerpt of the handoff objective, when present. - pub objective_preview: Option, - /// Bounded excerpt of the handoff summary, when present. - pub summary_preview: Option, - /// Character length of the original (un-truncated) handoff summary (`0` when none). - pub summary_len: usize, - /// Cursor (last turn id) covered by the handoff summary, when present. - pub up_to: Option, - /// Bounded, recent turns from the log fallback. - pub recent_turns: Vec, -} - -impl From for ConversationWorkSummaryDto { - fn from(summary: ConversationWorkSummary) -> Self { - Self { - conversation_id: summary.conversation_id.to_string(), - status: summary.status.into(), - objective_preview: summary.objective_preview, - summary_preview: summary.summary_preview, - summary_len: summary.summary_len, - up_to: summary.up_to.map(|cursor| cursor.to_string()), - recent_turns: summary - .recent_turns - .into_iter() - .map(ConversationTurnWorkPreviewDto::from) - .collect(), - } - } -} - -/// Project-level read model for conversation/delegation UX. -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct ProjectWorkStateDto { - /// Manifest agents in manifest order, enriched with live/busy state. - pub agents: Vec, - /// Best-effort summaries of the conversations referenced by the tickets, - /// joined frontend-side via `tickets[].conversationId`. - pub conversations: Vec, -} - -impl From for ProjectWorkStateDto { - fn from(state: ProjectWorkState) -> Self { - Self { - agents: state - .agents - .into_iter() - .map(|agent| AgentWorkStateDto { - agent_id: agent.agent_id.to_string(), - name: agent.name, - profile_id: agent.profile_id.to_string(), - live: agent.live.map(|live| LiveWorkSessionDto { - node_id: live.node_id.to_string(), - session_id: live.session_id.to_string(), - kind: live.kind.into(), - }), - busy: agent.busy, - tickets: agent - .tickets - .into_iter() - .map(AgentTicketStateDto::from) - .collect(), - background_tasks: agent - .background_tasks - .into_iter() - .map(AgentBackgroundTaskStateDto::from) - .collect(), - }) - .collect(), - conversations: state - .conversations - .into_iter() - .map(ConversationWorkSummaryDto::from) - .collect(), - } - } -} - -/// Request DTO for `attach_live_agent`: bind an already-running agent session to -/// a visible layout cell without spawning a new process. -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct AttachLiveAgentRequestDto { - /// Id of the owning project (validated for symmetry and future scoping). - pub project_id: String, - /// Id of the already-running agent. - pub agent_id: String, - /// Layout leaf that should display the live session. - pub node_id: String, -} - -/// Response DTO for `attach_live_agent`: the rebound live session's coordinates. -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct AttachLiveAgentResponseDto { - /// The rebound agent's id. - pub agent_id: String, - /// The node now hosting the session view. - pub node_id: String, - /// The (unchanged) live session id. - pub session_id: String, - /// Runtime family that owns the session (`pty`/`structured`). - pub kind: LiveWorkSessionKindDto, -} - -impl From for AttachLiveAgentResponseDto { - fn from(out: AttachLiveAgentOutput) -> Self { - Self { - agent_id: out.agent_id.to_string(), - node_id: out.node_id.to_string(), - session_id: out.session_id.to_string(), - kind: out.kind.into(), - } - } -} - -/// Request DTO for `stop_live_agent`: tear down an already-running agent's live -/// session by agent id (no spawn, agent entity preserved). -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct StopLiveAgentRequestDto { - /// Id of the owning project (validated for symmetry and future scoping). - pub project_id: String, - /// Id of the running agent to stop. - pub agent_id: String, -} - -/// Response DTO for `stop_live_agent`: the torn-down session's coordinates. -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct StopLiveAgentResponseDto { - /// The stopped agent's id. - pub agent_id: String, - /// The id of the session that was torn down. - pub session_id: String, - /// Runtime family that owned the session (`pty`/`structured`). - pub kind: LiveWorkSessionKindDto, -} - -impl From for StopLiveAgentResponseDto { - fn from(out: StopLiveAgentOutput) -> Self { - Self { - agent_id: out.agent_id.to_string(), - session_id: out.session_id.to_string(), - kind: out.kind.into(), - } - } -} - -/// Parses an agent-id string (UUID) coming from the frontend. -/// -/// # Errors -/// Returns an [`ErrorDto`] with code `INVALID` if the string is not a UUID. -pub fn parse_agent_id(raw: &str) -> Result { - uuid::Uuid::parse_str(raw) - .map(AgentId::from_uuid) - .map_err(|_| ErrorDto { - code: "INVALID".to_owned(), - message: format!("invalid agent id: {raw}"), - }) -} - -/// Parses a ticket-id string (UUID) coming from the frontend (`delegation_delivered`). -/// -/// # Errors -/// Returns an [`ErrorDto`] with code `INVALID` if the string is not a UUID. -pub fn parse_ticket_id(raw: &str) -> Result { - uuid::Uuid::parse_str(raw) - .map(domain::TicketId::from_uuid) - .map_err(|_| ErrorDto { - code: "INVALID".to_owned(), - message: format!("invalid ticket id: {raw}"), - }) -} - -// --------------------------------------------------------------------------- -// Resumable agents (§15.2 — Chantier B2) -// --------------------------------------------------------------------------- - -use application::{ListResumableAgentsOutput, ResumableAgent}; - -/// One resumable agent cell, as seen by the frontend (§15.2). -/// -/// Mirrors [`ResumableAgent`]: the agent's identity + its host cell, the CLI -/// conversation id to resume (absent ⇒ fresh relaunch), the `was_running` flag -/// frozen at close, and whether the agent's profile can resume a CLI -/// conversation. `conversationId` is **omitted from the wire when `None`** -/// (`skip_serializing_if`), so the TypeScript side sees an absent key — not -/// `null`. -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct ResumableAgentDto { - /// The resumable agent's id (UUID string). - pub agent_id: String, - /// The agent's display name (resolved from the manifest). - pub name: String, - /// The host layout leaf where the agent is relaunched/resumed (UUID string). - pub node_id: String, - /// Persistent CLI conversation id carried by the cell. Absent ⇒ fresh - /// relaunch (no history to resume). - #[serde(skip_serializing_if = "Option::is_none")] - pub conversation_id: Option, - /// The `agent_was_running` flag frozen at the cell's close. - pub was_running: bool, - /// `true` when the agent's profile carries a usable resume strategy. - pub resume_supported: bool, -} - -impl From for ResumableAgentDto { - fn from(r: ResumableAgent) -> Self { - Self { - agent_id: r.agent_id.to_string(), - name: r.name, - node_id: r.node_id.to_string(), - conversation_id: r.conversation_id, - was_running: r.was_running, - resume_supported: r.resume_supported, - } - } -} - -/// Response DTO for `list_resumable_agents` (§15.2). -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct ResumableAgentListDto { - /// The resumable agent cells, in layout-traversal order. - pub resumable: Vec, -} - -impl From for ResumableAgentListDto { - fn from(out: ListResumableAgentsOutput) -> Self { - Self { - resumable: out - .resumable - .into_iter() - .map(ResumableAgentDto::from) - .collect(), - } - } -} - -// --------------------------------------------------------------------------- -// Templates & sync (L7) -// --------------------------------------------------------------------------- - -use application::{ - AgentDrift, CreateAgentFromTemplateInput, CreateTemplateInput, CreateTemplateOutput, - DetectAgentDriftOutput, ListTemplatesOutput, SyncAgentWithTemplateOutput, UpdateTemplateInput, - UpdateTemplateOutput, -}; -use domain::{AgentTemplate, TemplateId}; - -/// A template crossing the wire. [`AgentTemplate`] already serialises camelCase -/// (`id`, `name`, `contentMd`, `version` as a number, `defaultProfileId`), -/// so we embed it directly — the TS mirror matches this shape. -#[derive(Debug, Clone, Serialize)] -#[serde(transparent)] -pub struct TemplateDto(pub AgentTemplate); - -/// A list of templates (transparent array on the wire). -#[derive(Debug, Clone, Serialize)] -#[serde(transparent)] -pub struct TemplateListDto(pub Vec); - -impl From for TemplateListDto { - fn from(out: ListTemplatesOutput) -> Self { - Self(out.templates.into_iter().map(TemplateDto).collect()) - } -} - -impl From for TemplateDto { - fn from(out: CreateTemplateOutput) -> Self { - Self(out.template) - } -} - -impl From for TemplateDto { - fn from(out: UpdateTemplateOutput) -> Self { - Self(out.template) - } -} - -/// Request DTO for `create_template`. -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct CreateTemplateRequestDto { - /// Display name. - pub name: String, - /// Initial Markdown content. - pub content: String, - /// Default runtime profile id for agents created from this template. - pub default_profile_id: String, -} - -impl CreateTemplateRequestDto { - /// Converts to the use-case input, parsing the profile id. - /// - /// # Errors - /// [`ErrorDto`] with code `INVALID` if the profile id is malformed. - pub fn into_input(self) -> Result { - Ok(CreateTemplateInput { - name: self.name, - content: self.content, - default_profile_id: parse_profile_id(&self.default_profile_id)?, - }) - } -} - -/// Request DTO for `update_template`. -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct UpdateTemplateRequestDto { - /// Id of the template to update. - pub template_id: String, - /// New Markdown content. - pub content: String, -} - -impl UpdateTemplateRequestDto { - /// Converts to the use-case input, parsing the template id. - /// - /// # Errors - /// [`ErrorDto`] with code `INVALID` if the template id is malformed. - pub fn into_input(self) -> Result { - Ok(UpdateTemplateInput { - template_id: parse_template_id(&self.template_id)?, - content: self.content, - }) - } -} - -/// Parses a template-id string (UUID) coming from the frontend. -/// -/// # Errors -/// Returns an [`ErrorDto`] with code `INVALID` if the string is not a UUID. -pub fn parse_template_id(raw: &str) -> Result { - uuid::Uuid::parse_str(raw) - .map(TemplateId::from_uuid) - .map_err(|_| ErrorDto { - code: "INVALID".to_owned(), - message: format!("invalid template id: {raw}"), - }) -} - -/// Request DTO for `create_agent_from_template`. -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct CreateAgentFromTemplateRequestDto { - /// Id of the owning project. - pub project_id: String, - /// Source template id. - pub template_id: String, - /// Optional agent name; defaults to the template's name when absent. - #[serde(default)] - pub name: Option, - /// Whether the agent tracks the template for future syncs. - pub synchronized: bool, -} - -impl CreateAgentFromTemplateRequestDto { - /// Converts to the use-case input, given the resolved project. - /// - /// # Errors - /// [`ErrorDto`] with code `INVALID` if the template id is malformed. - pub fn into_input( - self, - project: domain::Project, - ) -> Result { - Ok(CreateAgentFromTemplateInput { - project, - template_id: parse_template_id(&self.template_id)?, - name: self.name, - synchronized: self.synchronized, - }) - } -} - -/// One drifting agent, as seen by the frontend. -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct AgentDriftDto { - /// The drifting agent id (UUID string). - pub agent_id: String, - /// Version the agent is currently synced to. - pub from: u64, - /// Version available from the template. - pub to: u64, -} - -impl From for AgentDriftDto { - fn from(d: AgentDrift) -> Self { - Self { - agent_id: d.agent_id.to_string(), - from: d.from.get(), - to: d.to.get(), - } - } -} - -/// Response DTO for `detect_agent_drift` (transparent array on the wire). -#[derive(Debug, Clone, Serialize)] -#[serde(transparent)] -pub struct AgentDriftListDto(pub Vec); - -impl From for AgentDriftListDto { - fn from(out: DetectAgentDriftOutput) -> Self { - Self(out.drifts.into_iter().map(AgentDriftDto::from).collect()) - } -} - -/// Response DTO for `sync_agent_with_template`. -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct SyncResultDto { - /// Whether a sync was actually applied. - pub synced: bool, - /// The version the agent is now at (`null` when no sync happened). - pub version: Option, -} - -impl From for SyncResultDto { - fn from(out: SyncAgentWithTemplateOutput) -> Self { - Self { - synced: out.synced, - version: out.version.map(|v| v.get()), - } - } -} - -/// Request DTO for `sync_agent_with_template`. -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SyncAgentWithTemplateRequestDto { - /// Id of the owning project. - pub project_id: String, - /// Id of the agent to sync. - pub agent_id: String, -} - -// --------------------------------------------------------------------------- -// Git (L8) -// --------------------------------------------------------------------------- - -use application::{GitBranchesOutput, GitCommitOutput, GitLogOutput, GitStatusOutput}; -use domain::ports::{GitCommitInfo, GitFileStatus, GraphCommit}; - -/// One changed path returned by `git_status`. -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct GitFileStatusDto { - /// Repo-relative path. - pub path: String, - /// Whether the change is staged. - pub staged: bool, -} - -impl From for GitFileStatusDto { - fn from(s: GitFileStatus) -> Self { - Self { - path: s.path, - staged: s.staged, - } - } -} - -/// Response DTO for `git_status` (transparent array on the wire). -#[derive(Debug, Clone, Serialize)] -#[serde(transparent)] -pub struct GitStatusListDto(pub Vec); - -impl From for GitStatusListDto { - fn from(out: GitStatusOutput) -> Self { - Self( - out.entries - .into_iter() - .map(GitFileStatusDto::from) - .collect(), - ) - } -} - -/// A single commit summary crossing the wire. -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct GitCommitDto { - /// Commit hash. - pub hash: String, - /// Commit message summary. - pub summary: String, -} - -impl From for GitCommitDto { - fn from(c: GitCommitInfo) -> Self { - Self { - hash: c.hash, - summary: c.summary, - } - } -} - -impl From for GitCommitDto { - fn from(out: GitCommitOutput) -> Self { - Self::from(out.commit) - } -} - -/// Response DTO for `git_log` (transparent array on the wire). -#[derive(Debug, Clone, Serialize)] -#[serde(transparent)] -pub struct GitCommitListDto(pub Vec); - -impl From for GitCommitListDto { - fn from(out: GitLogOutput) -> Self { - Self(out.commits.into_iter().map(GitCommitDto::from).collect()) - } -} - -/// Response DTO for `git_branches`. -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct GitBranchesDto { - /// All local branches. - pub branches: Vec, - /// The current branch (`null` when detached or unborn). - pub current: Option, -} - -impl From for GitBranchesDto { - fn from(out: GitBranchesOutput) -> Self { - Self { - branches: out.branches, - current: out.current, - } - } -} - -/// A single commit enriched for graph display. -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct GraphCommitDto { - /// Full commit hash. - pub hash: String, - /// First line of the commit message. - pub summary: String, - /// Parent commit hashes. - pub parents: Vec, - /// Ref labels pointing at this commit (e.g. `"main"`, `"tag: v1.0"`). - pub refs: Vec, - /// Author name. - pub author: String, - /// Author timestamp in Unix seconds. - pub timestamp: i64, -} - -impl From for GraphCommitDto { - fn from(c: GraphCommit) -> Self { - Self { - hash: c.hash, - summary: c.summary, - parents: c.parents, - refs: c.refs, - author: c.author, - timestamp: c.timestamp, - } - } -} - -/// Response DTO for `git_graph` (transparent array on the wire). -#[derive(Debug, Clone, Serialize)] -#[serde(transparent)] -pub struct GraphCommitListDto(pub Vec); - -impl From for GraphCommitListDto { - fn from(out: GitGraphOutput) -> Self { - Self(out.commits.into_iter().map(GraphCommitDto::from).collect()) - } -} - -/// Request DTO for `git_stage` / `git_unstage`. -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct GitStageRequestDto { - /// Id of the owning project. - pub project_id: String, - /// Repo-relative path to (un)stage. - pub path: String, -} - -/// Request DTO for `git_commit`. -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct GitCommitRequestDto { - /// Id of the owning project. - pub project_id: String, - /// Commit message. - pub message: String, -} - -/// Request DTO for `git_checkout`. -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct GitCheckoutRequestDto { - /// Id of the owning project. - pub project_id: String, - /// Branch to check out. - pub branch: String, -} - -// --------------------------------------------------------------------------- -// Windows (L10) -// --------------------------------------------------------------------------- - -use application::MoveTabToNewWindowOutput; -use domain::ids::TabId; - -/// Response DTO for `move_tab_to_new_window`: the id minted for the new window -/// (used as the new OS window's label). -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct MoveTabResultDto { - /// The new window id (UUID string). - pub new_window_id: String, -} - -impl From for MoveTabResultDto { - fn from(out: MoveTabToNewWindowOutput) -> Self { - Self { - new_window_id: out.new_window_id.to_string(), - } - } -} - -/// Parses a tab-id string (UUID) coming from the frontend. -/// -/// # Errors -/// Returns an [`ErrorDto`] with code `INVALID` if the string is not a UUID. -pub fn parse_tab_id(raw: &str) -> Result { - uuid::Uuid::parse_str(raw) - .map(TabId::from_uuid) - .map_err(|_| ErrorDto { - code: "INVALID".to_owned(), - message: format!("invalid tab id: {raw}"), - }) -} - -// --------------------------------------------------------------------------- -// Skills (L12) -// --------------------------------------------------------------------------- - -use application::{CreateSkillOutput, ListSkillsOutput, UpdateSkillOutput}; -use domain::{Skill, SkillId, SkillScope}; - -/// A skill crossing the wire. [`Skill`] already serialises camelCase -/// (`id`, `name`, `contentMd`, `scope` as `"global"`/`"project"`), so we embed -/// it directly — the TS mirror matches this shape. -#[derive(Debug, Clone, Serialize)] -#[serde(transparent)] -pub struct SkillDto(pub Skill); - -/// A list of skills (transparent array on the wire). -#[derive(Debug, Clone, Serialize)] -#[serde(transparent)] -pub struct SkillListDto(pub Vec); - -impl From for SkillListDto { - fn from(out: ListSkillsOutput) -> Self { - Self(out.skills.into_iter().map(SkillDto).collect()) - } -} - -impl From for SkillDto { - fn from(out: CreateSkillOutput) -> Self { - Self(out.skill) - } -} - -impl From for SkillDto { - fn from(out: UpdateSkillOutput) -> Self { - Self(out.skill) - } -} - -/// Request DTO for `create_skill`. -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct CreateSkillRequestDto { - /// Owning project (resolved to a root; ignored on disk for `Global`). - pub project_id: String, - /// Display name. - pub name: String, - /// Initial Markdown content. - pub content: String, - /// Scope the skill is created in. - pub scope: SkillScope, -} - -/// Request DTO for `update_skill`. -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct UpdateSkillRequestDto { - /// Owning project (resolved to a root; ignored on disk for `Global`). - pub project_id: String, - /// Id of the skill to update. - pub skill_id: String, - /// Scope the skill lives in. - pub scope: SkillScope, - /// New Markdown content. - pub content: String, -} - -/// Request DTO for `assign_skill_to_agent`. -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct AssignSkillRequestDto { - /// Owning project. - pub project_id: String, - /// Agent receiving the skill. - pub agent_id: String, - /// Skill to assign. - pub skill_id: String, - /// Scope of the skill (recorded alongside the ref). - pub scope: SkillScope, -} - -/// Request DTO for `unassign_skill_from_agent`. -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct UnassignSkillRequestDto { - /// Owning project. - pub project_id: String, - /// Agent losing the skill. - pub agent_id: String, - /// Skill to unassign. - pub skill_id: String, -} - -/// Parses a skill-id string (UUID) coming from the frontend. -/// -/// # Errors -/// Returns an [`ErrorDto`] with code `INVALID` if the string is not a UUID. -pub fn parse_skill_id(raw: &str) -> Result { - uuid::Uuid::parse_str(raw) - .map(SkillId::from_uuid) - .map_err(|_| ErrorDto { - code: "INVALID".to_owned(), - message: format!("invalid skill id: {raw}"), - }) -} - -// --------------------------------------------------------------------------- -// Memory (LOT A — §14.5.1) -// --------------------------------------------------------------------------- - -use application::{ - CreateMemoryOutput, GetMemoryOutput, ListMemoriesOutput, ReadMemoryIndexOutput, - RecallMemoryOutput, ResolveMemoryLinksOutput, UpdateMemoryOutput, -}; -use domain::{Memory, MemoryIndexEntry, MemorySlug, MemoryType}; - -/// A memory note crossing the wire. -/// -/// Built explicitly from [`Memory`] (its `body` is [`domain::MarkdownDoc`], not -/// directly a JSON string) so the TS mirror gets a flat camelCase shape. -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct MemoryDto { - /// The note's slug (its identity, also the file stem). - pub name: String, - /// One-line description (the index hook). - pub description: String, - /// The note's kind. - pub r#type: MemoryType, - /// Markdown body of the note. - pub content: String, -} - -impl From for MemoryDto { - fn from(memory: Memory) -> Self { - Self { - name: memory.frontmatter.name.as_str().to_owned(), - description: memory.frontmatter.description, - r#type: memory.frontmatter.r#type, - content: memory.body.into_string(), - } - } -} - -impl From for MemoryDto { - fn from(out: CreateMemoryOutput) -> Self { - Self::from(out.memory) - } -} - -impl From for MemoryDto { - fn from(out: UpdateMemoryOutput) -> Self { - Self::from(out.memory) - } -} - -impl From for MemoryDto { - fn from(out: GetMemoryOutput) -> Self { - Self::from(out.memory) - } -} - -/// A list of memory notes (transparent array on the wire). -#[derive(Debug, Clone, Serialize)] -#[serde(transparent)] -pub struct MemoryListDto(pub Vec); - -impl From for MemoryListDto { - fn from(out: ListMemoriesOutput) -> Self { - Self(out.memories.into_iter().map(MemoryDto::from).collect()) - } -} - -/// One row of the structured `MEMORY.md` index crossing the wire. -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct MemoryIndexEntryDto { - /// The note's slug. - pub slug: String, - /// The note's display title. - pub title: String, - /// The one-line hook (the frontmatter description). - pub hook: String, - /// The note's kind. - pub r#type: MemoryType, -} - -impl From for MemoryIndexEntryDto { - fn from(entry: MemoryIndexEntry) -> Self { - Self { - slug: entry.slug.as_str().to_owned(), - title: entry.title, - hook: entry.hook, - r#type: entry.r#type, - } - } -} - -/// The structured memory index (transparent array on the wire). -#[derive(Debug, Clone, Serialize)] -#[serde(transparent)] -pub struct MemoryIndexDto(pub Vec); - -impl From for MemoryIndexDto { - fn from(out: ReadMemoryIndexOutput) -> Self { - Self( - out.entries - .into_iter() - .map(MemoryIndexEntryDto::from) - .collect(), - ) - } -} - -impl From for MemoryIndexDto { - fn from(out: RecallMemoryOutput) -> Self { - Self( - out.entries - .into_iter() - .map(MemoryIndexEntryDto::from) - .collect(), - ) - } -} - -/// A note's resolved outgoing links — the target slugs (transparent array). -#[derive(Debug, Clone, Serialize)] -#[serde(transparent)] -pub struct MemoryLinksDto(pub Vec); - -impl From for MemoryLinksDto { - fn from(out: ResolveMemoryLinksOutput) -> Self { - Self( - out.links - .into_iter() - .map(|link| link.target.as_str().to_owned()) - .collect(), - ) - } -} - -/// Request DTO for `create_memory`. -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct CreateMemoryRequestDto { - /// Owning project (resolved to a root). - pub project_id: String, - /// Raw slug for the new note. - pub name: String, - /// One-line description (the index hook). - pub description: String, - /// The note's kind. - pub r#type: MemoryType, - /// Markdown body of the note. - pub content: String, -} - -/// Request DTO for `update_memory`. -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct UpdateMemoryRequestDto { - /// Owning project (resolved to a root). - pub project_id: String, - /// Slug of the note to replace. - pub slug: String, - /// New description (the index hook). - pub description: String, - /// New kind. - pub r#type: MemoryType, - /// New Markdown body. - pub content: String, -} - -/// Request DTO for `recall_memory` (LOT B — §14.5.2). -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct RecallMemoryRequestDto { - /// Owning project (resolved to a root). - pub project_id: String, - /// The recall query (often the agent's current working context). - pub text: String, - /// Approximate token budget bounding the recalled entries (`0` ⇒ empty). - pub token_budget: usize, -} - -/// Parses a memory slug string coming from the frontend. -/// -/// # Errors -/// Returns an [`ErrorDto`] with code `INVALID` if the string is not a valid -/// kebab-case slug. -pub fn parse_memory_slug(raw: &str) -> Result { - MemorySlug::new(raw).map_err(|_| ErrorDto { - code: "INVALID".to_owned(), - message: format!("invalid memory slug: {raw}"), - }) -} - -// ── Conversation pagination (lot LS6) ──────────────────────────────────────── - -/// Request DTO for `read_conversation_page` — a human, paginated transcript read. -/// -/// `anchor` is a turn id to paginate around (omit to start from a thread end); -/// `direction` is `"forward"` (towards newer) or `"backward"` (towards older, -/// default — the human view opens on the latest page); `limit` is clamped by the -/// domain to `[1, 200]` (omit/`0` ⇒ default 50). -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ReadConversationPageRequestDto { - /// The owning project's id. - pub project_id: String, - /// The conversation (pair) id to read. - pub conversation_id: String, - /// Optional anchor turn id; omit to start from a thread end. - #[serde(default)] - pub anchor: Option, - /// Pagination direction (`"forward"` or `"backward"`); defaults to backward. - #[serde(default)] - pub direction: Option, - /// Requested page size (omit/`0` ⇒ default; clamped to `[1, 200]`). - #[serde(default)] - pub limit: Option, -} - -impl ReadConversationPageRequestDto { - /// Builds the domain [`PageCursor`] from the request (default direction backward, - /// an unparseable anchor degrades to `None` = a thread-end page). - #[must_use] - pub fn cursor(&self) -> PageCursor { - let direction = match self.direction.as_deref() { - Some(d) if d.eq_ignore_ascii_case("forward") => PageDirection::Forward, - _ => PageDirection::Backward, - }; - let anchor = self - .anchor - .as_deref() - .and_then(|raw| uuid::Uuid::parse_str(raw).ok()) - .map(domain::TurnId::from_uuid); - PageCursor { anchor, direction } - } -} - -/// Origin of a turn in the human transcript view (mirrors [`TurnSource`]). -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase", tag = "kind")] -pub enum TurnSourceDto { - /// The human operator. - Human, - /// Another agent (delegation via `idea_ask_agent`). - #[serde(rename_all = "camelCase")] - Agent { - /// The originating agent id. - agent_id: String, - }, -} - -impl From for TurnSourceDto { - fn from(source: TurnSource) -> Self { - match source { - TurnSource::Human => Self::Human, - TurnSource::Agent { agent_id } => Self::Agent { - agent_id: agent_id.to_string(), - }, - } - } -} - -/// One turn in the human transcript — **full text, never truncated**. -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct TurnViewDto { - /// Stable turn id (also the pagination anchor). - pub id: String, - /// Timestamp (epoch milliseconds). - pub at_ms: u64, - /// Turn nature (prompt, response, tool activity). - pub role: TurnRole, - /// Origin (human or delegating agent). - pub source: TurnSourceDto, - /// The turn's **complete** text (not truncated). - pub text: String, - /// Character length of the text. - pub text_len: usize, -} - -impl From for TurnViewDto { - fn from(turn: TurnView) -> Self { - Self { - id: turn.id.to_string(), - at_ms: turn.at_ms, - role: turn.role, - source: turn.source.into(), - text: turn.text, - text_len: turn.text_len, - } - } -} - -/// A page of the human transcript, oldest-to-newest. -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct TurnPageDto { - /// The page's turns, oldest to newest. - pub turns: Vec, - /// Whether more turns exist beyond the page in the travel direction. - pub has_more: bool, - /// Last turn id of the page (anchor for the next request), or `None` if empty. - pub next_anchor: Option, -} - -impl From for TurnPageDto { - fn from(page: TurnPage) -> Self { - Self { - turns: page.turns.into_iter().map(TurnViewDto::from).collect(), - has_more: page.has_more, - next_anchor: page.next_anchor.map(|id| id.to_string()), - } - } -} - -#[cfg(test)] -mod ls6_pagination_tests { - //! LS6 — DTO de la lecture humaine paginée : parsing du curseur + mapping riche - //! `TurnPage → TurnPageDto` (texte complet non borné, source Human/Agent, `has_more`, - //! `next_anchor`). C'est le « round-trip d'une page » testable sans `State` Tauri. - - use super::*; - use application::{TurnPage, TurnSource, TurnView}; - - fn tid(n: u128) -> domain::TurnId { - domain::TurnId::from_uuid(uuid::Uuid::from_u128(n)) - } - - #[test] - fn request_cursor_defaults_to_backward_and_parses_anchor() { - // Pas de direction ⇒ backward (la vue humaine ouvre sur la page la plus récente). - let req = ReadConversationPageRequestDto { - project_id: "p".into(), - conversation_id: "c".into(), - anchor: None, - direction: None, - limit: None, - }; - let cur = req.cursor(); - assert_eq!(cur.direction, PageDirection::Backward); - assert_eq!(cur.anchor, None); - - // Forward (insensible à la casse) + ancre UUID valide. - let u = uuid::Uuid::from_u128(9); - let req = ReadConversationPageRequestDto { - project_id: "p".into(), - conversation_id: "c".into(), - anchor: Some(u.to_string()), - direction: Some("Forward".into()), - limit: Some(10), - }; - let cur = req.cursor(); - assert_eq!(cur.direction, PageDirection::Forward); - assert_eq!(cur.anchor, Some(domain::TurnId::from_uuid(u))); - - // Ancre non-UUID ⇒ dégrade en None (page depuis un bout du fil), jamais d'erreur. - let req = ReadConversationPageRequestDto { - project_id: "p".into(), - conversation_id: "c".into(), - anchor: Some("not-a-uuid".into()), - direction: Some("backward".into()), - limit: None, - }; - assert_eq!(req.cursor().anchor, None); - } - - #[test] - fn turn_page_dto_round_trips_full_text_and_maps_sources() { - let big = "Z".repeat(40_000); - let page = TurnPage { - turns: vec![ - TurnView { - id: tid(1), - at_ms: 1_700_000_000_000, - role: TurnRole::Prompt, - source: TurnSource::Human, - text: big.clone(), - text_len: big.chars().count(), - }, - TurnView { - id: tid(2), - at_ms: 1_700_000_000_001, - role: TurnRole::Response, - source: TurnSource::Agent { - agent_id: domain::AgentId::from_uuid(uuid::Uuid::from_u128(7)), - }, - text: "réponse".to_owned(), - text_len: 7, - }, - ], - has_more: true, - next_anchor: Some(tid(2)), - }; - - let dto = TurnPageDto::from(page); - // Texte complet préservé (non borné) + text_len. - assert_eq!(dto.turns[0].text.chars().count(), 40_000); - assert_eq!(dto.turns[0].text_len, 40_000); - // next_anchor stringifié, has_more porté. - assert!(dto.has_more); - assert_eq!( - dto.next_anchor.as_deref(), - Some(tid(2).to_string().as_str()) - ); - - // Round-trip JSON (la « page » telle qu'envoyée au front) : camelCase + source. - let json = serde_json::to_value(&dto).unwrap(); - assert_eq!(json["hasMore"], serde_json::json!(true)); - assert_eq!(json["nextAnchor"], serde_json::json!(tid(2).to_string())); - assert_eq!( - json["turns"][0]["source"]["kind"], - serde_json::json!("human") - ); - assert_eq!( - json["turns"][1]["source"]["kind"], - serde_json::json!("agent") - ); - assert_eq!( - json["turns"][1]["source"]["agentId"], - serde_json::json!(uuid::Uuid::from_u128(7).to_string()) - ); - // Le texte intégral survit au passage JSON. - assert_eq!( - json["turns"][0]["text"].as_str().unwrap().chars().count(), - 40_000 - ); - } - - #[test] - fn empty_turn_page_dto_has_no_next_anchor() { - let dto = TurnPageDto::from(TurnPage { - turns: Vec::new(), - has_more: false, - next_anchor: None, - }); - assert!(dto.turns.is_empty() && !dto.has_more && dto.next_anchor.is_none()); - } -} - -// --------------------------------------------------------------------------- -// Background tasks (B8 — first-class background command) -// --------------------------------------------------------------------------- - -use domain::{ - BackgroundTask, BackgroundTaskKind, BackgroundTaskResult, BackgroundTaskState, TaskId, -}; - -/// One first-class background task as seen by the frontend (camelCase wire shape). -/// -/// Mirrors the persisted [`BackgroundTask`], flattening the terminal -/// [`BackgroundTaskResult`] into optional `exitCode` / `summary` / tails so the -/// UI has a single shape for every lifecycle state. Optional fields are omitted -/// from the wire when absent (`skip_serializing_if`). -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct BackgroundTaskDto { - /// Stable task id (UUID string). - pub task_id: String, - /// Owning agent id (UUID string). - pub owner_agent_id: String, - /// Owning project id (UUID string). - pub project_id: String, - /// Kind discriminant (`"command"`, `"headlessRendezvous"`, `"sessionResume"`, - /// `"maintenance"`). - pub kind: String, - /// Lifecycle state (`"queued"`, `"running"`, `"waiting"`, `"completed"`, - /// `"failed"`, `"cancelled"`, `"expired"`). - pub state: String, - /// Process exit code, when the terminal result carries one. - #[serde(skip_serializing_if = "Option::is_none")] - pub exit_code: Option, - /// Human-readable summary / error / reason of the terminal result. - #[serde(skip_serializing_if = "Option::is_none")] - pub summary: Option, - /// Bounded stdout tail (merged output for PTY-backed commands). - #[serde(skip_serializing_if = "Option::is_none")] - pub stdout_tail: Option, - /// Bounded stderr tail (unset for PTY-backed commands, which merge streams). - #[serde(skip_serializing_if = "Option::is_none")] - pub stderr_tail: Option, - /// Creation timestamp, epoch milliseconds. - pub created_at_ms: u64, - /// Last update timestamp, epoch milliseconds. - pub updated_at_ms: u64, -} - -/// Kind discriminant string for a [`BackgroundTaskKind`]. -fn background_kind_label(kind: &BackgroundTaskKind) -> &'static str { - match kind { - BackgroundTaskKind::Command { .. } => "command", - BackgroundTaskKind::HeadlessRendezvous { .. } => "headlessRendezvous", - BackgroundTaskKind::SessionResume { .. } => "sessionResume", - BackgroundTaskKind::Maintenance { .. } => "maintenance", - } -} - -/// Kind discriminant string for a work-state [`BackgroundTaskKindLabel`]. -fn background_kind_label_from_work_state(kind: BackgroundTaskKindLabel) -> &'static str { - match kind { - BackgroundTaskKindLabel::Command => "command", - BackgroundTaskKindLabel::HeadlessRendezvous => "headlessRendezvous", - BackgroundTaskKindLabel::SessionResume => "sessionResume", - BackgroundTaskKindLabel::Maintenance => "maintenance", - } -} - -/// Lifecycle state string for a [`BackgroundTaskState`]. -fn background_state_label(state: BackgroundTaskState) -> &'static str { - match state { - BackgroundTaskState::Queued => "queued", - BackgroundTaskState::Running => "running", - BackgroundTaskState::Waiting => "waiting", - BackgroundTaskState::Completed => "completed", - BackgroundTaskState::Failed => "failed", - BackgroundTaskState::Cancelled => "cancelled", - BackgroundTaskState::Expired => "expired", - } -} - -impl From for BackgroundTaskDto { - fn from(task: BackgroundTask) -> Self { - let (exit_code, summary, stdout_tail, stderr_tail) = match &task.result { - Some(BackgroundTaskResult::Success { - exit_code, - summary, - stdout_tail, - stderr_tail, - .. - }) => ( - *exit_code, - Some(summary.clone()), - stdout_tail.clone(), - stderr_tail.clone(), - ), - Some(BackgroundTaskResult::Failure { - exit_code, - error, - stdout_tail, - stderr_tail, - .. - }) => ( - *exit_code, - Some(error.clone()), - stdout_tail.clone(), - stderr_tail.clone(), - ), - Some(BackgroundTaskResult::Cancelled { reason, .. }) - | Some(BackgroundTaskResult::Expired { reason, .. }) => { - (None, Some(reason.clone()), None, None) - } - None => (None, None, None, None), - }; - Self { - task_id: task.id.to_string(), - owner_agent_id: task.owner_agent_id.to_string(), - project_id: task.project_id.to_string(), - kind: background_kind_label(&task.kind).to_owned(), - state: background_state_label(task.state).to_owned(), - exit_code, - summary, - stdout_tail, - stderr_tail, - created_at_ms: task.created_at_ms, - updated_at_ms: task.updated_at_ms, - } - } -} - -/// Parses a task-id string (UUID) coming from the frontend. -/// -/// # Errors -/// Returns an [`ErrorDto`] with code `INVALID` if the string is not a UUID. -pub fn parse_task_id(raw: &str) -> Result { - uuid::Uuid::parse_str(raw) - .map(TaskId::from_uuid) - .map_err(|_| ErrorDto { - code: "INVALID".to_owned(), - message: format!("invalid task id: {raw}"), - }) -} - -/// Request DTO for `spawn_background_command`. -/// -/// Builds a command-backed [`BackgroundTask`]: the command line runs under the -/// project host's PTY and its completion is delivered to `ownerAgentId`. -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SpawnBackgroundCommandRequestDto { - /// Owning project id (UUID string). - pub project_id: String, - /// Agent that owns completion delivery (UUID string). - pub owner_agent_id: String, - /// Human-facing label. - pub label: String, - /// Executable to run. - pub command: String, - /// Arguments. - #[serde(default)] - pub args: Vec, - /// Working directory (absolute path). - pub cwd: String, - /// Extra environment variables. - #[serde(default)] - pub env: Vec<(String, String)>, - /// When `true` (the default), wake the owner on completion; otherwise only - /// record it. - #[serde(default)] - pub record_only: bool, - /// Optional absolute deadline, epoch milliseconds. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub deadline_ms: Option, -} +pub use backend::dto::*; diff --git a/crates/app-tauri/src/events.rs b/crates/app-tauri/src/events.rs index caf0478..3f353cc 100644 --- a/crates/app-tauri/src/events.rs +++ b/crates/app-tauri/src/events.rs @@ -1,1114 +1,22 @@ -//! `TauriEventRelay` — bridges the domain [`EventBus`] to Tauri events -//! (backend → frontend push channel, ARCHITECTURE §2 "Events"). +//! Tauri event relay. //! -//! 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. +//! The stable serialisable event DTOs live in `backend::events`. This module +//! owns only the Tauri-specific relay: subscribing to the domain event bus and +//! emitting named Tauri events. -use serde::Serialize; +pub use backend::events::{ + AgentLaunchFailedDto, DomainEventDto, ModelServerStatusChangedDto, OrchestrationSourceDto, + AGENT_LAUNCH_FAILED, DOMAIN_EVENT, MODEL_SERVER_STATUS_CHANGED, +}; +use domain::events::DomainEvent; +use infrastructure::TokioBroadcastEventBus; use tauri::{AppHandle, Emitter}; -use domain::conversation::ConversationParty; -use domain::events::{DomainEvent, OrchestrationSource}; -use domain::input::AgentLiveness; -use domain::model_server::ModelServerLifecycleStatus; -use domain::{IssueLinkKind, IssuePriority, IssueStatus}; -use infrastructure::TokioBroadcastEventBus; - -/// Name of the Tauri event carrying relayed [`DomainEvent`]s. -pub const DOMAIN_EVENT: &str = "domain://event"; -/// Dedicated Tauri event for local model-server status changes. -pub const MODEL_SERVER_STATUS_CHANGED: &str = "model_server_status_changed"; -/// Dedicated Tauri event for launch failures. -pub const AGENT_LAUNCH_FAILED: &str = "agent_launch_failed"; - -/// Model-server lifecycle status on the Tauri wire. -#[derive(Debug, Clone, Serialize)] -#[serde(tag = "state", rename_all = "camelCase")] -pub enum ModelServerStatusDto { - /// No server is configured for this profile. - NotConfigured, - /// Readiness probing is in progress. - Probing, - /// IdeA is starting the process. - Starting, - /// Server is downloading/preparing a remote model. - #[serde(rename_all = "camelCase")] - Downloading { - /// Downloaded bytes when known. - downloaded_bytes: Option, - /// Total bytes when known. - total_bytes: Option, - /// Completion percentage when known. - percent: Option, - /// Remote model source. - source: Option, - }, - /// Server is ready. - #[serde(rename_all = "camelCase")] - Ready { - /// `true` when an existing server was reused. - reused: bool, - }, - /// Server preparation failed. - #[serde(rename_all = "camelCase")] - Failed { - /// Stable failure code. - code: String, - /// Human-readable message. - message: String, - }, -} - -impl From<&ModelServerLifecycleStatus> for ModelServerStatusDto { - fn from(status: &ModelServerLifecycleStatus) -> Self { - match status { - ModelServerLifecycleStatus::NotConfigured => Self::NotConfigured, - ModelServerLifecycleStatus::Probing => Self::Probing, - ModelServerLifecycleStatus::Starting => Self::Starting, - ModelServerLifecycleStatus::Downloading { - downloaded_bytes, - total_bytes, - percent, - source, - } => Self::Downloading { - downloaded_bytes: *downloaded_bytes, - total_bytes: *total_bytes, - percent: *percent, - source: source.clone(), - }, - ModelServerLifecycleStatus::Ready { reused } => Self::Ready { reused: *reused }, - ModelServerLifecycleStatus::Failed { code, message } => Self::Failed { - code: code.clone(), - message: message.clone(), - }, - } - } -} - -/// Payload for [`MODEL_SERVER_STATUS_CHANGED`]. -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct ModelServerStatusChangedDto { - /// Local model server id. - pub server_id: String, - /// Lifecycle status. - pub status: ModelServerStatusDto, -} - -/// Payload for [`AGENT_LAUNCH_FAILED`]. -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct AgentLaunchFailedDto { - /// Agent id. - pub agent_id: String, - /// Failure cause namespace. - pub cause: String, - /// Stable error code. - pub code: String, - /// Human-readable message. - pub message: String, -} - -/// 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 launch failed before runtime session creation. - #[serde(rename_all = "camelCase")] - AgentLaunchFailed { - /// Agent id. - agent_id: String, - /// Failure cause namespace. - cause: String, - /// Stable error code. - code: String, - /// Human-readable message. - message: String, - }, - /// Local model-server status changed. - #[serde(rename_all = "camelCase")] - ModelServerStatusChanged { - /// Local model server id. - server_id: String, - /// Lifecycle status. - status: ModelServerStatusDto, - }, - /// 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, - /// Profile's submit delay in ms; `null` ⇒ the front default (~60 ms). - #[serde(skip_serializing_if = "Option::is_none")] - submit_delay_ms: Option, - }, - /// 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, - }, - /// 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, - }, - /// 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, - /// New sprint id. - to: Option, - /// 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, - }, - /// 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, - /// 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, - }, - /// 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, - }, - /// 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, - }, -} - -/// 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 for AgentLivenessDto { - fn from(liveness: AgentLiveness) -> Self { - match liveness { - AgentLiveness::Alive => Self::Alive, - AgentLiveness::Stalled => Self::Stalled, - } - } -} - -impl From 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::AgentLaunchFailed { - agent_id, - cause, - code, - message, - } => Self::AgentLaunchFailed { - agent_id: agent_id.to_string(), - cause: cause.clone(), - code: code.clone(), - message: message.clone(), - }, - DomainEvent::ModelServerStatusChanged { server_id, status } => { - Self::ModelServerStatusChanged { - server_id: server_id.to_string(), - status: status.into(), - } - } - 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. +/// every non-PTY [`DomainEvent`] to the frontend as Tauri events. /// -/// 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. +/// High-frequency `PtyOutput` is intentionally not relayed through this global +/// event; it goes through per-session PTY channels instead. pub fn spawn_relay(app: AppHandle, bus: &TokioBroadcastEventBus) { use tokio::sync::broadcast::error::RecvError; @@ -1117,12 +25,13 @@ pub fn spawn_relay(app: AppHandle, bus: &TokioBroadcastEventBus) { 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); + match &event { DomainEvent::ModelServerStatusChanged { server_id, status } => { let _ = app.emit( @@ -1152,229 +61,9 @@ pub fn spawn_relay(app: AppHandle, bus: &TokioBroadcastEventBus) { _ => {} } } - // 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::{LocalModelServerId, ProjectId}; - use serde_json::json; - - fn agent(n: u128) -> AgentId { - AgentId::from_uuid(uuid::Uuid::from_u128(n)) - } - - fn server(n: u128) -> LocalModelServerId { - LocalModelServerId::from_uuid(uuid::Uuid::from_u128(n)) - } - - #[test] - fn model_server_status_changed_relays_ready_to_dto_and_wire() { - let dto = DomainEventDto::from(&DomainEvent::ModelServerStatusChanged { - server_id: server(35), - status: ModelServerLifecycleStatus::Ready { reused: true }, - }); - - let json = serde_json::to_value(&dto).expect("serialisable"); - assert_eq!(json["type"], "modelServerStatusChanged"); - assert_eq!(json["serverId"], server(35).to_string()); - assert_eq!(json["status"]["state"], "ready"); - assert_eq!(json["status"]["reused"], true); - } - - #[test] - fn model_server_status_changed_relays_downloading_to_dto_and_wire() { - let dto = DomainEventDto::from(&DomainEvent::ModelServerStatusChanged { - server_id: server(37), - status: ModelServerLifecycleStatus::Downloading { - downloaded_bytes: None, - total_bytes: None, - percent: None, - source: Some("Qwen/Qwen3-Coder-30B-A3B-Instruct-GGUF".to_owned()), - }, - }); - - let json = serde_json::to_value(&dto).expect("serialisable"); - assert_eq!(json["type"], "modelServerStatusChanged"); - assert_eq!(json["serverId"], server(37).to_string()); - assert_eq!(json["status"]["state"], "downloading"); - assert_eq!(json["status"]["downloadedBytes"], serde_json::Value::Null); - assert_eq!(json["status"]["totalBytes"], serde_json::Value::Null); - assert_eq!(json["status"]["percent"], serde_json::Value::Null); - assert_eq!( - json["status"]["source"], - "Qwen/Qwen3-Coder-30B-A3B-Instruct-GGUF" - ); - } - - #[test] - fn agent_launch_failed_relays_model_server_cause() { - let dto = DomainEventDto::from(&DomainEvent::AgentLaunchFailed { - agent_id: agent(36), - cause: "model_server".to_owned(), - code: "path_not_accessible".to_owned(), - message: "path not accessible: /models/missing.gguf".to_owned(), - }); - - let json = serde_json::to_value(&dto).expect("serialisable"); - assert_eq!(json["type"], "agentLaunchFailed"); - assert_eq!(json["agentId"], agent(36).to_string()); - assert_eq!(json["cause"], "model_server"); - assert_eq!(json["code"], "path_not_accessible"); - } - - /// 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"); - } -} diff --git a/crates/app-tauri/src/server.rs b/crates/app-tauri/src/server.rs index 24d3be5..40a13ae 100644 --- a/crates/app-tauri/src/server.rs +++ b/crates/app-tauri/src/server.rs @@ -1,4029 +1,12 @@ -//! Secure HTTP driving adapter for `idea --serve`. +//! Compatibility wrapper for the historical `idea --serve` desktop binary path. //! -//! The shared backend core stays unaware of HTTP, cookies, origins and -//! WebSocket framing; this module owns the secure web driving adapter. +//! The HTTP/WebSocket adapter itself lives in `web-server`, so the standalone +//! `idea-serve` binary can be built without Tauri/WebKit dependencies. -use std::collections::HashSet; -use std::env; -use std::net::SocketAddr; -use std::path::{Path, PathBuf}; use std::process::ExitCode; -use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::{Arc, Mutex}; - -use backend::stream::{OutputBridge, OutputSink, OutputSinkError}; -use bytes::Bytes; -use cookie::{Cookie, SameSite}; -use domain::events::DomainEvent; -use domain::ports::BackgroundTaskPortError; -use http::header::{HeaderValue, CONTENT_TYPE, COOKIE, ORIGIN, SET_COOKIE}; -#[cfg(test)] -use http::Request; -use http::{HeaderMap, Method, Response, StatusCode, Uri}; -use http_body_util::{BodyExt, Full}; -use serde::{Deserialize, Serialize}; -use serde_json::{json, Value}; -use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt}; -use tokio::net::TcpListener; -use tokio::sync::mpsc; -use uuid::Uuid; - -use application::{ - CloseTerminalInput, GetProjectWorkStateInput, LaunchAgentInput, McpRuntime, OpenProjectInput, - ResizeTerminalInput, RotateConversationLogInput, WriteToTerminalInput, -}; -use domain::ports::PtyHandle; -use domain::{Project, SessionId}; - -use crate::dto::{ - parse_agent_id, parse_node_id, parse_project_id, parse_session_id, parse_task_id, - BackgroundTaskDto, ErrorDto, HealthRequestDto, HealthResponseDto, LaunchAgentRequestDto, - OpenTerminalRequestDto, ProjectDto, ProjectListDto, ProjectWorkStateDto, TerminalSessionDto, -}; -use crate::events::DomainEventDto; -use crate::pty::PtyChunk; -use crate::state::AppState; - -const DEFAULT_LISTEN: &str = "127.0.0.1:17373"; -const SESSION_COOKIE: &str = "idea_session"; -const WS_PATH: &str = "/api/ws"; -const WS_MAGIC: &str = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; -const WS_MAX_PAYLOAD: usize = 64 * 1024; -const WS_OUTPUT_BUFFER: usize = 512; - -type ResponseBody = Full; /// Runs the `idea --serve` subcommand from already-split CLI arguments. +#[must_use] pub fn run_from_args(args: Vec) -> ExitCode { - let config = match ServerConfig::from_args(args).and_then(|config| { - config.validate()?; - Ok(config) - }) { - Ok(config) => config, - Err(err) => { - eprintln!("idea --serve: {err}"); - return ExitCode::from(2); - } - }; - - let state = Arc::new(ServerState::new(config.clone())); - eprintln!("IdeA pairing code: {}", state.pairing_code()); - eprintln!( - "idea --serve: app data dir = {}", - config.app_data_dir.display() - ); - eprintln!("IdeA server listening on {}", config.listen); - - match tokio::runtime::Builder::new_multi_thread() - .enable_all() - .build() - { - Ok(runtime) => match runtime.block_on(run_server(config, state)) { - Ok(()) => ExitCode::SUCCESS, - Err(err) => { - eprintln!("idea --serve: {err}"); - ExitCode::from(1) - } - }, - Err(err) => { - eprintln!("idea --serve: failed to build runtime: {err}"); - ExitCode::from(1) - } - } -} - -#[derive(Clone)] -struct ServerConfig { - listen: SocketAddr, - public_origin: Option, - allow_remote: bool, - trust_reverse_proxy: bool, - app_data_dir: PathBuf, - web_root: PathBuf, -} - -impl ServerConfig { - fn from_args(args: Vec) -> Result { - let mut listen = DEFAULT_LISTEN - .parse::() - .expect("default listen address is valid"); - let mut public_origin = None; - let mut allow_remote = false; - let mut trust_reverse_proxy = false; - let mut app_data_dir = default_app_data_dir(); - let mut web_root = None; - - let mut it = args.into_iter(); - while let Some(arg) = it.next() { - match arg.as_str() { - "--listen" => { - let value = it - .next() - .ok_or_else(|| "--listen requires an address".to_owned())?; - listen = value - .parse() - .map_err(|_| format!("invalid --listen address: {value}"))?; - } - "--public-origin" => { - let value = it - .next() - .ok_or_else(|| "--public-origin requires an origin".to_owned())?; - public_origin = Some(value); - } - "--allow-remote" => allow_remote = true, - "--trust-reverse-proxy" => trust_reverse_proxy = true, - "--app-data-dir" => { - let value = it - .next() - .ok_or_else(|| "--app-data-dir requires a path".to_owned())?; - app_data_dir = PathBuf::from(value); - } - "--web-root" => { - let value = it - .next() - .ok_or_else(|| "--web-root requires a path".to_owned())?; - web_root = Some(PathBuf::from(value)); - } - "--help" | "-h" => return Err(Self::usage()), - other => return Err(format!("unknown --serve argument: {other}")), - } - } - let web_root = resolve_web_root(web_root)?; - - Ok(Self { - listen, - public_origin, - allow_remote, - trust_reverse_proxy, - app_data_dir, - web_root, - }) - } - - fn validate(&self) -> Result<(), String> { - if let Some(origin) = &self.public_origin { - validate_origin(origin)?; - } - - if !self.listen.ip().is_loopback() && !self.allow_remote { - return Err( - "refusing non-loopback bind without --allow-remote and HTTPS proxy config" - .to_owned(), - ); - } - - if self.allow_remote { - let Some(origin) = &self.public_origin else { - return Err("--allow-remote requires --public-origin https://...".to_owned()); - }; - if !origin.starts_with("https://") { - return Err("--allow-remote requires an HTTPS public origin".to_owned()); - } - if !self.trust_reverse_proxy { - return Err("--allow-remote requires --trust-reverse-proxy".to_owned()); - } - } - - Ok(()) - } - - fn secure_cookie(&self) -> bool { - self.allow_remote - || self - .public_origin - .as_deref() - .is_some_and(|o| o.starts_with("https://")) - } - - fn usage() -> String { - "usage: idea --serve [--listen IP:PORT] [--app-data-dir PATH] [--web-root PATH] [--allow-remote --public-origin https://host --trust-reverse-proxy]".to_owned() - } -} - -fn resolve_web_root(explicit: Option) -> Result { - if let Some(path) = explicit { - return validate_web_root(path, "--web-root"); - } - if let Some(path) = env::var_os("IDEA_WEB_ROOT").map(PathBuf::from) { - return validate_web_root(path, "IDEA_WEB_ROOT"); - } - - let mut candidates = Vec::new(); - if let Ok(exe) = env::current_exe() { - if let Some(dir) = exe.parent() { - candidates.push(dir.join("web")); - candidates.push(dir.join("frontend").join("dist")); - } - } - if let Ok(cwd) = env::current_dir() { - candidates.push(cwd.join("frontend").join("dist")); - } - - for candidate in candidates { - if candidate.join("index.html").is_file() { - return Ok(candidate); - } - } - - Err("web assets not found: build frontend/dist or pass --web-root PATH".to_owned()) -} - -fn validate_web_root(path: PathBuf, source: &str) -> Result { - if path.join("index.html").is_file() { - Ok(path) - } else { - Err(format!( - "{source} does not contain an index.html: {}", - path.display() - )) - } -} - -struct ServerState { - config: ServerConfig, - app: AppState, - pairing_code: String, - sessions: Mutex>, - ws_pty_bridge: Arc>, - security_logger: Arc, -} - -impl ServerState { - fn new(config: ServerConfig) -> Self { - Self { - app: AppState::build(config.app_data_dir.clone()), - config, - pairing_code: new_pairing_code(), - sessions: Mutex::new(HashSet::new()), - ws_pty_bridge: Arc::new(OutputBridge::new()), - security_logger: Arc::new(StderrSecurityLogger), - } - } - - #[cfg(test)] - fn new_for_test(config: ServerConfig, pairing_code: impl Into) -> Self { - Self::new_for_test_with_logger(config, pairing_code, Arc::new(NoopSecurityLogger)) - } - - #[cfg(test)] - fn new_for_test_with_logger( - config: ServerConfig, - pairing_code: impl Into, - security_logger: Arc, - ) -> Self { - Self { - app: AppState::build(config.app_data_dir.clone()), - config, - pairing_code: pairing_code.into(), - sessions: Mutex::new(HashSet::new()), - ws_pty_bridge: Arc::new(OutputBridge::new()), - security_logger, - } - } - - fn pairing_code(&self) -> &str { - &self.pairing_code - } - - fn create_session(&self) -> String { - let token = new_session_token(); - if let Ok(mut sessions) = self.sessions.lock() { - sessions.insert(token.clone()); - } - token - } - - fn has_session(&self, token: &str) -> bool { - self.sessions - .lock() - .map(|sessions| sessions.contains(token)) - .unwrap_or(false) - } - - fn revoke_session(&self, token: &str) -> bool { - self.sessions - .lock() - .map(|mut sessions| sessions.remove(token)) - .unwrap_or(false) - } - - fn log_security(&self, event: SecurityLogEvent) { - self.security_logger.log(event); - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -enum SecurityLogEvent { - PairingSucceeded { - origin: Option, - }, - PairingFailed { - origin: Option, - reason: &'static str, - }, - OriginRejected { - origin: Option, - route: String, - }, - WsUpgradeRejected { - origin: Option, - reason: &'static str, - }, - SessionRevoked { - origin: Option, - }, -} - -trait SecurityLogger: Send + Sync { - fn log(&self, event: SecurityLogEvent); -} - -struct StderrSecurityLogger; - -impl SecurityLogger for StderrSecurityLogger { - fn log(&self, event: SecurityLogEvent) { - eprintln!("idea --serve security: {}", security_log_line(&event)); - } -} - -#[cfg(test)] -struct NoopSecurityLogger; - -#[cfg(test)] -impl SecurityLogger for NoopSecurityLogger { - fn log(&self, _event: SecurityLogEvent) {} -} - -fn security_log_line(event: &SecurityLogEvent) -> String { - match event { - SecurityLogEvent::PairingSucceeded { origin } => { - format!("pairing succeeded origin={}", origin_label(origin)) - } - SecurityLogEvent::PairingFailed { origin, reason } => { - format!( - "pairing failed reason={reason} origin={}", - origin_label(origin) - ) - } - SecurityLogEvent::OriginRejected { origin, route } => { - format!( - "origin rejected route={} origin={}", - route, - origin_label(origin) - ) - } - SecurityLogEvent::WsUpgradeRejected { origin, reason } => { - format!( - "websocket upgrade rejected reason={reason} origin={}", - origin_label(origin) - ) - } - SecurityLogEvent::SessionRevoked { origin } => { - format!("session revoked origin={}", origin_label(origin)) - } - } -} - -fn origin_label(origin: &Option) -> &str { - origin.as_deref().unwrap_or("") -} - -async fn run_server(config: ServerConfig, state: Arc) -> Result<(), String> { - let listener = TcpListener::bind(config.listen) - .await - .map_err(|err| format!("failed to bind {}: {err}", config.listen))?; - - loop { - let (stream, _) = listener - .accept() - .await - .map_err(|err| format!("failed to accept connection: {err}"))?; - let state = Arc::clone(&state); - tokio::spawn(async move { - if let Err(err) = handle_tcp_connection(stream, state).await { - eprintln!("idea --serve: connection error: {err}"); - } - }); - } -} - -async fn handle_tcp_connection( - mut stream: tokio::net::TcpStream, - state: Arc, -) -> Result<(), String> { - let mut buffer = Vec::with_capacity(8192); - let mut chunk = [0_u8; 2048]; - let header_end = loop { - let read = stream - .read(&mut chunk) - .await - .map_err(|err| format!("failed to read request: {err}"))?; - if read == 0 { - return Ok(()); - } - buffer.extend_from_slice(&chunk[..read]); - if buffer.len() > 1024 * 1024 { - return Err("request too large".to_owned()); - } - if let Some(pos) = find_header_end(&buffer) { - break pos; - } - }; - - let (method, uri, headers, content_length) = parse_http_request_head(&buffer[..header_end])?; - if method == Method::GET && uri.path() == WS_PATH { - return handle_ws_upgrade(stream, headers, state).await; - } - - let body_start = header_end + 4; - while buffer.len() < body_start + content_length { - let read = stream - .read(&mut chunk) - .await - .map_err(|err| format!("failed to read request body: {err}"))?; - if read == 0 { - break; - } - buffer.extend_from_slice(&chunk[..read]); - if buffer.len() > 1024 * 1024 { - return Err("request too large".to_owned()); - } - } - if buffer.len() < body_start + content_length { - return Err("truncated request body".to_owned()); - } - - let response = dispatch_http( - method, - uri, - headers, - Bytes::copy_from_slice(&buffer[body_start..body_start + content_length]), - state, - ) - .await; - write_http_response(&mut stream, response).await -} - -#[cfg(test)] -async fn handle_request( - req: Request, - state: Arc, -) -> Response { - let (parts, body) = req.into_parts(); - let body = match body.collect().await { - Ok(body) => body.to_bytes(), - Err(err) => { - return error_response( - StatusCode::BAD_REQUEST, - "INVALID", - format!("invalid request body: {err}"), - None, - ); - } - }; - - dispatch_http( - parts.method, - parts.uri, - parts.headers, - body, - Arc::clone(&state), - ) - .await -} - -fn find_header_end(buffer: &[u8]) -> Option { - buffer.windows(4).position(|window| window == b"\r\n\r\n") -} - -fn parse_http_request_head(head: &[u8]) -> Result<(Method, Uri, HeaderMap, usize), String> { - let text = std::str::from_utf8(head).map_err(|_| "request head is not UTF-8".to_owned())?; - let mut lines = text.split("\r\n"); - let request_line = lines - .next() - .ok_or_else(|| "missing request line".to_owned())?; - let mut request_parts = request_line.split_whitespace(); - let method = request_parts - .next() - .ok_or_else(|| "missing method".to_owned())? - .parse::() - .map_err(|_| "invalid method".to_owned())?; - let uri = request_parts - .next() - .ok_or_else(|| "missing uri".to_owned())? - .parse::() - .map_err(|_| "invalid uri".to_owned())?; - let mut headers = HeaderMap::new(); - let mut content_length = 0; - - for line in lines { - if line.is_empty() { - continue; - } - let Some((name, value)) = line.split_once(':') else { - return Err("invalid header line".to_owned()); - }; - let name = http::header::HeaderName::from_bytes(name.trim().as_bytes()) - .map_err(|_| "invalid header name".to_owned())?; - let value = - HeaderValue::from_str(value.trim()).map_err(|_| "invalid header value".to_owned())?; - if name == http::header::CONTENT_LENGTH { - content_length = value - .to_str() - .ok() - .and_then(|raw| raw.parse::().ok()) - .ok_or_else(|| "invalid content-length".to_owned())?; - } - headers.insert(name, value); - } - - Ok((method, uri, headers, content_length)) -} - -async fn write_http_response( - stream: &mut tokio::net::TcpStream, - response: Response, -) -> Result<(), String> { - let status = response.status(); - let headers = response.headers().clone(); - let body = response - .into_body() - .collect() - .await - .map_err(|err| format!("failed to collect response: {err}"))? - .to_bytes(); - let reason = status.canonical_reason().unwrap_or("Unknown"); - let mut bytes = format!("HTTP/1.1 {} {}\r\n", status.as_u16(), reason).into_bytes(); - for (name, value) in &headers { - bytes.extend_from_slice(name.as_str().as_bytes()); - bytes.extend_from_slice(b": "); - bytes.extend_from_slice(value.as_bytes()); - bytes.extend_from_slice(b"\r\n"); - } - bytes.extend_from_slice(format!("content-length: {}\r\n", body.len()).as_bytes()); - bytes.extend_from_slice(b"connection: close\r\n\r\n"); - bytes.extend_from_slice(&body); - stream - .write_all(&bytes) - .await - .map_err(|err| format!("failed to write response: {err}")) -} - -async fn handle_ws_upgrade( - mut stream: tokio::net::TcpStream, - headers: HeaderMap, - state: Arc, -) -> Result<(), String> { - let accept = match validate_ws_upgrade(&headers, &state) { - Ok(accept) => accept, - Err(response) => return write_http_response(&mut stream, *response).await, - }; - - let response = format!( - "HTTP/1.1 101 Switching Protocols\r\n\ - upgrade: websocket\r\n\ - connection: Upgrade\r\n\ - sec-websocket-accept: {accept}\r\n\r\n" - ); - stream - .write_all(response.as_bytes()) - .await - .map_err(|err| format!("failed to write websocket upgrade: {err}"))?; - run_ws_connection(stream, state).await -} - -fn validate_ws_upgrade( - headers: &HeaderMap, - state: &ServerState, -) -> Result>> { - let origin = match validate_request_origin(headers, &state.config) { - Ok(origin) => origin, - Err(response) => { - state.log_security(SecurityLogEvent::OriginRejected { - origin: request_origin(headers), - route: WS_PATH.to_owned(), - }); - return Err(response); - } - }; - let Some(token) = session_cookie(headers) else { - state.log_security(SecurityLogEvent::WsUpgradeRejected { - origin: origin.clone(), - reason: "missing_session", - }); - return Err(Box::new(error_response( - StatusCode::UNAUTHORIZED, - "UNAUTHORIZED", - "missing session cookie", - origin.as_deref(), - ))); - }; - if !state.has_session(&token) { - state.log_security(SecurityLogEvent::WsUpgradeRejected { - origin: origin.clone(), - reason: "invalid_session", - }); - return Err(Box::new(error_response( - StatusCode::UNAUTHORIZED, - "UNAUTHORIZED", - "invalid session cookie", - origin.as_deref(), - ))); - } - if !header_contains_token(headers, "upgrade", "websocket") - || !header_contains_token(headers, "connection", "upgrade") - { - state.log_security(SecurityLogEvent::WsUpgradeRejected { - origin: origin.clone(), - reason: "invalid_upgrade", - }); - return Err(Box::new(error_response( - StatusCode::BAD_REQUEST, - "INVALID", - "missing websocket upgrade headers", - origin.as_deref(), - ))); - } - let Some(key) = headers - .get("sec-websocket-key") - .and_then(|value| value.to_str().ok()) - else { - state.log_security(SecurityLogEvent::WsUpgradeRejected { - origin: origin.clone(), - reason: "missing_key", - }); - return Err(Box::new(error_response( - StatusCode::BAD_REQUEST, - "INVALID", - "missing Sec-WebSocket-Key", - origin.as_deref(), - ))); - }; - Ok(websocket_accept(key)) -} - -fn header_contains_token(headers: &HeaderMap, name: &str, needle: &str) -> bool { - headers - .get(name) - .and_then(|value| value.to_str().ok()) - .is_some_and(|value| { - value - .split(',') - .any(|token| token.trim().eq_ignore_ascii_case(needle)) - }) -} - -async fn run_ws_connection( - stream: tokio::net::TcpStream, - state: Arc, -) -> Result<(), String> { - let (mut reader, mut writer) = stream.into_split(); - let (tx, mut rx) = mpsc::channel::(WS_OUTPUT_BUFFER); - let owned = Arc::new(Mutex::new(Vec::<(SessionId, u64)>::new())); - let event_relay_task = - spawn_ws_domain_event_relay(state.app.event_bus.raw_receiver(), tx.clone()); - let writer_task = tokio::spawn(async move { - while let Some(frame) = rx.recv().await { - let text = serde_json::to_vec(&frame) - .map_err(|err| format!("failed to encode server frame: {err}"))?; - let bytes = encode_ws_frame(WsOpcode::Text, &text); - writer - .write_all(&bytes) - .await - .map_err(|err| format!("failed to write websocket frame: {err}"))?; - } - Ok::<(), String>(()) - }); - - loop { - let frame = match read_ws_frame(&mut reader).await { - Ok(frame) => frame, - Err(err) => { - let _ = tx - .send(ServerFrame::error( - None, - None, - "WS_PROTOCOL", - err.to_string(), - )) - .await; - break; - } - }; - match frame.opcode { - WsOpcode::Text => { - let parsed = serde_json::from_slice::(&frame.payload) - .map_err(|err| format!("invalid client frame JSON: {err}")); - match parsed { - Ok(frame) => { - handle_client_frame(frame, &state, &tx, &owned).await; - } - Err(err) => { - let _ = tx - .send(ServerFrame::error(None, None, "INVALID", err)) - .await; - } - } - } - WsOpcode::Ping => { - let _ = tx.send(ServerFrame::pong()).await; - } - WsOpcode::Close => break, - WsOpcode::Pong => {} - WsOpcode::Binary => { - let _ = tx - .send(ServerFrame::error( - None, - None, - "UNSUPPORTED", - "binary websocket frames are not supported", - )) - .await; - } - } - } - - if let Ok(owned) = owned.lock() { - for (session, gen) in owned.iter() { - state.ws_pty_bridge.unregister_if(session, *gen); - } - } - event_relay_task.abort(); - drop(tx); - writer_task - .await - .map_err(|err| format!("websocket writer task failed: {err}"))? -} - -fn spawn_ws_domain_event_relay( - mut rx: tokio::sync::broadcast::Receiver, - tx: mpsc::Sender, -) -> tokio::task::JoinHandle<()> { - use tokio::sync::broadcast::error::RecvError; - - tokio::spawn(async move { - loop { - match rx.recv().await { - Ok(event) => { - if matches!(event, DomainEvent::PtyOutput { .. }) { - continue; - } - match tx.try_send(ServerFrame::domain_event(&event)) { - Ok(()) | Err(mpsc::error::TrySendError::Full(_)) => {} - Err(mpsc::error::TrySendError::Closed(_)) => break, - } - } - Err(RecvError::Lagged(_)) => continue, - Err(RecvError::Closed) => break, - } - } - }) -} - -async fn handle_client_frame( - frame: ClientFrame, - state: &Arc, - tx: &mpsc::Sender, - owned: &Arc>>, -) { - let result = match frame.kind.as_str() { - "terminal.open" | "open_terminal" => ws_open_terminal(&frame, state, tx, owned).await, - "agent.launch" | "launch_agent" => ws_launch_agent(&frame, state, tx, owned).await, - "terminal.attach" | "attach_terminal" => ws_attach_terminal(&frame, state, tx, owned).await, - "terminal.input" | "input" => ws_input(&frame, state), - "terminal.resize" | "resize" => ws_resize(&frame, state), - "terminal.detach" | "detach" => ws_detach(&frame, state, owned), - "terminal.close" | "close" => match ws_close(&frame, state, owned).await { - Ok((session_id, exit_code)) => { - let _ = tx - .send(ServerFrame::status(session_id, "exited", exit_code)) - .await; - Ok(()) - } - Err(err) => Err(err), - }, - "ping" => { - let _ = tx.send(ServerFrame::pong()).await; - Ok(()) - } - _ => Err(ErrorDto { - code: "UNKNOWN_FRAME".to_owned(), - message: format!("unknown websocket frame kind: {}", frame.kind), - }), - }; - - if let Err(err) = result { - let _ = tx - .send(ServerFrame::error( - Some(frame.id), - payload_session_id(&frame.payload), - err.code, - err.message, - )) - .await; - } -} - -async fn ws_open_terminal( - frame: &ClientFrame, - state: &Arc, - tx: &mpsc::Sender, - owned: &Arc>>, -) -> Result<(), ErrorDto> { - let request_value = frame - .payload - .get("request") - .cloned() - .unwrap_or_else(|| frame.payload.clone()); - let request: OpenTerminalRequestDto = - serde_json::from_value(request_value).map_err(invalid_args_error)?; - let output = state - .app - .open_terminal - .execute(request.into()) - .await - .map_err(ErrorDto::from)?; - let dto = TerminalSessionDto::from(output); - let sid = parse_session_id(&dto.session_id)?; - let sink = WsPtySink::new(sid, tx.clone(), 0); - tx.send(ServerFrame::attached(&frame.id, &dto, Vec::new(), 0, false)) - .await - .map_err(|_| ErrorDto { - code: "WS_CLOSED".to_owned(), - message: "websocket output closed".to_owned(), - })?; - attach_sink_and_pump(state, sid, sink, owned) -} - -async fn ws_launch_agent( - frame: &ClientFrame, - state: &Arc, - tx: &mpsc::Sender, - owned: &Arc>>, -) -> Result<(), ErrorDto> { - let request_value = frame - .payload - .get("request") - .cloned() - .unwrap_or_else(|| frame.payload.clone()); - let request: LaunchAgentRequestDto = - serde_json::from_value(request_value).map_err(invalid_args_error)?; - let output = execute_launch_agent_for_ws(state, request).await?; - send_launch_agent_attached(frame, state, tx, owned, output).await -} - -async fn send_launch_agent_attached( - frame: &ClientFrame, - state: &Arc, - tx: &mpsc::Sender, - owned: &Arc>>, - output: application::LaunchAgentOutput, -) -> Result<(), ErrorDto> { - if output.structured.is_some() { - return Err(ErrorDto { - code: "UNSUPPORTED".to_owned(), - message: "structured agent sessions do not stream over the PTY websocket".to_owned(), - }); - } - let session_id = output.session.id; - let dto = TerminalSessionDto::from(output); - let sid = parse_session_id(&dto.session_id)?; - let sink = WsPtySink::new(sid, tx.clone(), 0); - tx.send(ServerFrame::attached(&frame.id, &dto, Vec::new(), 0, false)) - .await - .map_err(|_| ErrorDto { - code: "WS_CLOSED".to_owned(), - message: "websocket output closed".to_owned(), - })?; - attach_sink_and_pump(state, session_id, sink, owned) -} - -async fn execute_launch_agent_for_ws( - state: &Arc, - request: LaunchAgentRequestDto, -) -> Result { - let project = resolve_project_readonly(&request.project_id, &state.app).await?; - let agent_id = parse_agent_id(&request.agent_id)?; - let node_id = request.node_id.as_deref().map(parse_node_id).transpose()?; - let mcp_runtime = crate::mcp_endpoint::idea_exe_path().map(|exe| McpRuntime { - exe, - endpoint: crate::mcp_endpoint::mcp_endpoint(&project.id) - .as_cli_arg() - .to_owned(), - project_id: project.id.as_uuid().simple().to_string(), - requester: agent_id.to_string(), - }); - - state.app.reconcile_claude_run_dirs(&project).await; - - let resume_project = project.clone(); - let rotation_root = project.root.clone(); - let watch_root = project.root.clone(); - let output = state - .app - .launch_agent - .execute(LaunchAgentInput { - project, - agent_id, - rows: request.rows, - cols: request.cols, - node_id, - conversation_id: request.conversation_id.clone(), - mcp_runtime, - allow_structured_alongside_pty: false, - }) - .await - .map_err(ErrorDto::from)?; - - if let Ok(mut contexts) = state.app.resume_contexts.lock() { - contexts.insert( - agent_id, - crate::state::ResumeContext { - project: resume_project, - rows: request.rows, - cols: request.cols, - }, - ); - } - - if let Some(profile) = output.profile.as_ref() { - state.app.arm_turn_watch( - &watch_root, - agent_id, - profile, - output.assigned_conversation_id.clone(), - ); - } - - if let Some(conversation) = request - .conversation_id - .as_deref() - .and_then(|raw| uuid::Uuid::parse_str(raw).ok()) - .map(domain::ConversationId::from_uuid) - { - let rotate = Arc::clone(&state.app.rotate_conversation_log); - tokio::spawn(async move { - let _ = rotate - .execute(RotateConversationLogInput { - project_root: rotation_root, - conversation, - }) - .await; - }); - } - - Ok(output) -} - -async fn ws_attach_terminal( - frame: &ClientFrame, - state: &Arc, - tx: &mpsc::Sender, - owned: &Arc>>, -) -> Result<(), ErrorDto> { - let session_id = payload_string(&frame.payload, "sessionId")?; - let sid = parse_session_id(&session_id)?; - let handle = PtyHandle { session_id: sid }; - let scrollback = state - .app - .pty_port - .scrollback(&handle) - .map_err(|err| ErrorDto::from(application::AppError::from(err)))?; - let rows = payload_u16(&frame.payload, "rows").unwrap_or(24); - let cols = payload_u16(&frame.payload, "cols").unwrap_or(80); - let dto = TerminalSessionDto { - session_id, - cwd: String::new(), - rows, - cols, - assigned_conversation_id: None, - engine_session_id: None, - cell_kind: crate::dto::CellKind::Pty, - }; - let next_seq = u64::from(!scrollback.is_empty()); - tx.send(ServerFrame::attached( - &frame.id, - &dto, - scrollback.clone(), - next_seq, - frame.payload.get("lastSeq").is_some(), - )) - .await - .map_err(|_| ErrorDto { - code: "WS_CLOSED".to_owned(), - message: "websocket output closed".to_owned(), - })?; - let sink = WsPtySink::new(sid, tx.clone(), next_seq); - attach_sink_and_pump(state, sid, sink, owned) -} - -fn attach_sink_and_pump( - state: &Arc, - sid: SessionId, - sink: WsPtySink, - owned: &Arc>>, -) -> Result<(), ErrorDto> { - let gen = state.ws_pty_bridge.register(sid, Arc::new(sink)); - if let Ok(mut owned) = owned.lock() { - owned.push((sid, gen)); - } - let handle = PtyHandle { session_id: sid }; - let stream = state - .app - .pty_port - .subscribe_output(&handle) - .map_err(|err| ErrorDto::from(application::AppError::from(err)))?; - let bridge = Arc::clone(&state.ws_pty_bridge); - std::thread::spawn(move || { - for chunk in stream { - if !bridge.send_output(&sid, chunk) { - break; - } - } - bridge.unregister_if(&sid, gen); - }); - Ok(()) -} - -fn ws_input(frame: &ClientFrame, state: &Arc) -> Result<(), ErrorDto> { - let sid = parse_session_id(&payload_string(&frame.payload, "sessionId")?)?; - let bytes = payload_bytes(&frame.payload)?; - state - .app - .write_terminal - .execute(WriteToTerminalInput { - session_id: sid, - data: bytes, - }) - .map_err(ErrorDto::from) -} - -fn ws_resize(frame: &ClientFrame, state: &Arc) -> Result<(), ErrorDto> { - let sid = parse_session_id(&payload_string(&frame.payload, "sessionId")?)?; - let rows = payload_u16(&frame.payload, "rows").ok_or_else(|| ErrorDto { - code: "INVALID".to_owned(), - message: "resize requires rows".to_owned(), - })?; - let cols = payload_u16(&frame.payload, "cols").ok_or_else(|| ErrorDto { - code: "INVALID".to_owned(), - message: "resize requires cols".to_owned(), - })?; - state - .app - .resize_terminal - .execute(ResizeTerminalInput { - session_id: sid, - rows, - cols, - }) - .map_err(ErrorDto::from) -} - -fn ws_detach( - frame: &ClientFrame, - state: &Arc, - owned: &Arc>>, -) -> Result<(), ErrorDto> { - let sid = parse_session_id(&payload_string(&frame.payload, "sessionId")?)?; - if let Ok(mut owned) = owned.lock() { - if let Some(index) = owned.iter().rposition(|(session, _)| *session == sid) { - let (_, gen) = owned.remove(index); - state.ws_pty_bridge.unregister_if(&sid, gen); - } - } - Ok(()) -} - -async fn ws_close( - frame: &ClientFrame, - state: &Arc, - owned: &Arc>>, -) -> Result<(SessionId, Option), ErrorDto> { - let sid = parse_session_id(&payload_string(&frame.payload, "sessionId")?)?; - ws_detach(frame, state, owned)?; - let output = state - .app - .close_terminal - .execute(CloseTerminalInput { session_id: sid }) - .await - .map_err(ErrorDto::from)?; - Ok((sid, output.code)) -} - -async fn dispatch_http( - method: Method, - uri: Uri, - headers: HeaderMap, - body: Bytes, - state: Arc, -) -> Response { - if has_forbidden_query_secret(&uri) { - return error_response( - StatusCode::BAD_REQUEST, - "INVALID", - "secrets in URL query strings are forbidden", - None, - ); - } - - if !is_api_path(uri.path()) { - return serve_static(&state.config.web_root, method, uri.path()).await; - } - - let origin = match validate_request_origin(&headers, &state.config) { - Ok(origin) => origin, - Err(response) => { - state.log_security(SecurityLogEvent::OriginRejected { - origin: request_origin(&headers), - route: uri.path().to_owned(), - }); - return *response; - } - }; - - if method == Method::OPTIONS { - return cors_response(StatusCode::NO_CONTENT, origin.as_deref()); - } - - match (method, uri.path()) { - (Method::POST, "/api/pair") => pair(body, state, origin.as_deref()).await, - (Method::POST, "/api/logout") => { - let Some(token) = session_cookie(&headers) else { - return error_response( - StatusCode::UNAUTHORIZED, - "UNAUTHORIZED", - "missing session cookie", - origin.as_deref(), - ); - }; - if !state.revoke_session(&token) { - return error_response( - StatusCode::UNAUTHORIZED, - "UNAUTHORIZED", - "invalid session cookie", - origin.as_deref(), - ); - } - state.log_security(SecurityLogEvent::SessionRevoked { - origin: origin.clone(), - }); - logout_response(&state, origin.as_deref()) - } - (Method::POST, "/api/invoke") => { - let Some(token) = session_cookie(&headers) else { - return error_response( - StatusCode::UNAUTHORIZED, - "UNAUTHORIZED", - "missing session cookie", - origin.as_deref(), - ); - }; - if !state.has_session(&token) { - return error_response( - StatusCode::UNAUTHORIZED, - "UNAUTHORIZED", - "invalid session cookie", - origin.as_deref(), - ); - } - invoke(body, state, origin.as_deref()).await - } - (_, "/api/pair" | "/api/invoke" | "/api/logout") => error_response( - StatusCode::METHOD_NOT_ALLOWED, - "METHOD_NOT_ALLOWED", - "method not allowed", - origin.as_deref(), - ), - _ if is_api_path(uri.path()) => error_response( - StatusCode::NOT_FOUND, - "NOT_FOUND", - "route not found", - origin.as_deref(), - ), - _ => error_response(StatusCode::NOT_FOUND, "NOT_FOUND", "route not found", None), - } -} - -fn is_api_path(path: &str) -> bool { - path == "/api" || path.starts_with("/api/") -} - -async fn serve_static(web_root: &Path, method: Method, path: &str) -> Response { - if !matches!(method, Method::GET | Method::HEAD) { - return error_response( - StatusCode::METHOD_NOT_ALLOWED, - "METHOD_NOT_ALLOWED", - "method not allowed", - None, - ); - } - - let Some(route) = static_route(web_root, path) else { - return error_response(StatusCode::NOT_FOUND, "NOT_FOUND", "route not found", None); - }; - - let file = if route.candidate.is_file() { - route.candidate - } else if route.spa_fallback { - web_root.join("index.html") - } else { - return error_response(StatusCode::NOT_FOUND, "NOT_FOUND", "route not found", None); - }; - - let bytes = match tokio::fs::read(&file).await { - Ok(bytes) => bytes, - Err(_) => { - return error_response(StatusCode::NOT_FOUND, "NOT_FOUND", "route not found", None); - } - }; - static_file_response(&file, bytes, method == Method::HEAD) -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct StaticRoute { - candidate: PathBuf, - spa_fallback: bool, -} - -fn static_route(web_root: &Path, request_path: &str) -> Option { - if !request_path.starts_with('/') || request_path.contains('\\') { - return None; - } - let lower = request_path.to_ascii_lowercase(); - if lower.contains("%2e") || lower.contains("%2f") || lower.contains("%5c") { - return None; - } - - let mut candidate = web_root.to_path_buf(); - let mut last = ""; - for segment in request_path - .split('/') - .filter(|segment| !segment.is_empty()) - { - if segment == "." || segment == ".." || segment.starts_with('.') || segment.contains('%') { - return None; - } - candidate.push(segment); - last = segment; - } - - if request_path == "/" { - return Some(StaticRoute { - candidate: web_root.join("index.html"), - spa_fallback: false, - }); - } - - Some(StaticRoute { - candidate, - spa_fallback: !last.contains('.'), - }) -} - -fn static_file_response(path: &Path, bytes: Vec, head_only: bool) -> Response { - let is_index = path - .file_name() - .and_then(|name| name.to_str()) - .is_some_and(|name| name == "index.html"); - let body = if head_only { Vec::new() } else { bytes }; - let mut response = Response::builder() - .status(StatusCode::OK) - .header(CONTENT_TYPE, content_type(path)) - .header("x-content-type-options", "nosniff") - .body(Full::new(Bytes::from(body))) - .expect("static response is valid"); - if is_index { - response.headers_mut().insert( - "content-security-policy", - HeaderValue::from_static( - "default-src 'self'; connect-src 'self' ws: wss:; img-src 'self' data: blob:; style-src 'self' 'unsafe-inline'; script-src 'self'; base-uri 'self'; frame-ancestors 'none'", - ), - ); - } - response -} - -fn content_type(path: &Path) -> &'static str { - match path.extension().and_then(|ext| ext.to_str()).unwrap_or("") { - "html" => "text/html; charset=utf-8", - "js" | "mjs" => "text/javascript; charset=utf-8", - "css" => "text/css; charset=utf-8", - "json" => "application/json; charset=utf-8", - "svg" => "image/svg+xml", - "png" => "image/png", - "jpg" | "jpeg" => "image/jpeg", - "gif" => "image/gif", - "webp" => "image/webp", - "ico" => "image/x-icon", - "wasm" => "application/wasm", - "txt" => "text/plain; charset=utf-8", - _ => "application/octet-stream", - } -} - -#[derive(Deserialize)] -struct PairRequest { - code: String, -} - -async fn pair( - body: Bytes, - state: Arc, - origin: Option<&str>, -) -> Response { - let request = match serde_json::from_slice::(&body) { - Ok(request) => request, - Err(err) => { - return error_response( - StatusCode::BAD_REQUEST, - "INVALID", - format!("invalid pairing request: {err}"), - origin, - ); - } - }; - - if request.code != state.pairing_code() { - state.log_security(SecurityLogEvent::PairingFailed { - origin: origin.map(str::to_owned), - reason: "wrong_code", - }); - return error_response( - StatusCode::FORBIDDEN, - "FORBIDDEN", - "invalid pairing code", - origin, - ); - } - - let token = state.create_session(); - state.log_security(SecurityLogEvent::PairingSucceeded { - origin: origin.map(str::to_owned), - }); - let cookie = Cookie::build((SESSION_COOKIE, token)) - .path("/") - .http_only(true) - .secure(state.config.secure_cookie()) - .same_site(SameSite::Strict) - .build(); - let mut response = json_response(StatusCode::OK, &json!({ "paired": true }), origin); - response.headers_mut().insert( - SET_COOKIE, - HeaderValue::from_str(&cookie.to_string()).expect("session cookie is header-safe"), - ); - response -} - -fn logout_response(state: &ServerState, origin: Option<&str>) -> Response { - let mut response = json_response(StatusCode::OK, &json!({ "revoked": true }), origin); - let secure = if state.config.secure_cookie() { - "; Secure" - } else { - "" - }; - let cookie = format!("{SESSION_COOKIE}=; Path=/; HttpOnly{secure}; SameSite=Strict; Max-Age=0"); - response.headers_mut().insert( - SET_COOKIE, - HeaderValue::from_str(&cookie).expect("session clearing cookie is header-safe"), - ); - response -} - -#[derive(Deserialize)] -struct InvokeRequest { - command: String, - #[serde(default)] - args: Value, -} - -async fn invoke( - body: Bytes, - state: Arc, - origin: Option<&str>, -) -> Response { - let request = match serde_json::from_slice::(&body) { - Ok(request) => request, - Err(err) => { - return error_response( - StatusCode::BAD_REQUEST, - "INVALID", - format!("invalid invoke request: {err}"), - origin, - ); - } - }; - - let result = match request.command.as_str() { - "health" => invoke_health(&request.args, &state.app), - "list_projects" => invoke_list_projects(&state.app).await, - "open_project" => invoke_open_project(&request.args, &state.app).await, - "get_project_work_state" => invoke_get_project_work_state(&request.args, &state.app).await, - "list_background_tasks" => invoke_list_background_tasks(&request.args, &state.app).await, - "cancel_background_task" => invoke_cancel_background_task(&request.args, &state.app).await, - "retry_background_task" => invoke_retry_background_task(&request.args, &state.app).await, - _ => Err(ErrorDto { - code: "UNKNOWN_COMMAND".to_owned(), - message: format!("unknown command: {}", request.command), - }), - }; - - match result { - Ok(value) => json_response(StatusCode::OK, &value, origin), - Err(error) => error_dto_response(status_for_error(&error), error, origin), - } -} - -fn invoke_health(args: &Value, state: &AppState) -> Result { - let request = optional_request::(args)?; - let output = state - .health - .execute(request.unwrap_or_default().into()) - .map(HealthResponseDto::from) - .map_err(ErrorDto::from)?; - serde_json::to_value(output).map_err(serialization_error) -} - -async fn invoke_list_projects(state: &AppState) -> Result { - let output = state - .list_projects - .execute() - .await - .map(ProjectListDto::from) - .map_err(ErrorDto::from)?; - serde_json::to_value(output).map_err(serialization_error) -} - -async fn invoke_open_project(args: &Value, state: &AppState) -> Result { - let project_id = args - .get("projectId") - .and_then(Value::as_str) - .ok_or_else(|| ErrorDto { - code: "INVALID".to_owned(), - message: "open_project requires args.projectId".to_owned(), - })?; - let project = resolve_project_readonly(project_id, state).await?; - serde_json::to_value(ProjectDto::from(project)).map_err(serialization_error) -} - -async fn invoke_get_project_work_state(args: &Value, state: &AppState) -> Result { - let project_id = args - .get("projectId") - .and_then(Value::as_str) - .ok_or_else(|| ErrorDto { - code: "INVALID".to_owned(), - message: "get_project_work_state requires args.projectId".to_owned(), - })?; - let project = resolve_project_readonly(project_id, state).await?; - let output = state - .get_project_work_state - .execute(GetProjectWorkStateInput { project }) - .await - .map(ProjectWorkStateDto::from) - .map_err(ErrorDto::from)?; - serde_json::to_value(output).map_err(serialization_error) -} - -async fn invoke_list_background_tasks(args: &Value, state: &AppState) -> Result { - let project_id = args - .get("projectId") - .and_then(Value::as_str) - .ok_or_else(|| ErrorDto { - code: "INVALID".to_owned(), - message: "list_background_tasks requires args.projectId".to_owned(), - }) - .and_then(parse_project_id)?; - let agent_id = args - .get("agentId") - .and_then(Value::as_str) - .map(parse_agent_id) - .transpose()?; - - let mut by_id = std::collections::HashMap::::new(); - if let Some(agent_id) = agent_id { - for task in state - .background_task_store - .list_open_for_agent(agent_id) - .await - .map_err(background_error)? - { - by_id.insert(task.id, task); - } - } - for task in state - .background_task_store - .list_undelivered_completions() - .await - .map_err(background_error)? - { - by_id.entry(task.id).or_insert(task); - } - - let mut tasks = by_id - .into_values() - .filter(|task| task.project_id == project_id) - .filter(|task| agent_id.map_or(true, |agent_id| task.owner_agent_id == agent_id)) - .collect::>(); - tasks.sort_by_key(|task| (task.created_at_ms, task.id)); - let dto = tasks - .into_iter() - .map(BackgroundTaskDto::from) - .collect::>(); - serde_json::to_value(dto).map_err(serialization_error) -} - -async fn invoke_cancel_background_task(args: &Value, state: &AppState) -> Result { - let task_id = task_id_arg(args, "cancel_background_task")?; - verify_background_task_project(args, state, task_id).await?; - let output = state - .cancel_background_task - .execute(task_id) - .await - .map_err(ErrorDto::from)?; - serde_json::to_value(output.task.map(BackgroundTaskDto::from)).map_err(serialization_error) -} - -async fn invoke_retry_background_task(args: &Value, state: &AppState) -> Result { - let task_id = task_id_arg(args, "retry_background_task")?; - verify_background_task_project(args, state, task_id).await?; - let output = state - .retry_background_task - .execute(task_id) - .await - .map_err(ErrorDto::from)?; - serde_json::to_value(BackgroundTaskDto::from(output.task)).map_err(serialization_error) -} - -fn task_id_arg(args: &Value, command: &str) -> Result { - args.get("taskId") - .and_then(Value::as_str) - .ok_or_else(|| ErrorDto { - code: "INVALID".to_owned(), - message: format!("{command} requires args.taskId"), - }) - .and_then(parse_task_id) -} - -async fn verify_background_task_project( - args: &Value, - state: &AppState, - task_id: domain::TaskId, -) -> Result<(), ErrorDto> { - let Some(project_id) = args.get("projectId").and_then(Value::as_str) else { - return Ok(()); - }; - let project_id = parse_project_id(project_id)?; - let Some(task) = state - .background_task_store - .get(task_id) - .await - .map_err(background_error)? - else { - return Err(ErrorDto { - code: "NOT_FOUND".to_owned(), - message: format!("background task not found: {task_id}"), - }); - }; - if task.project_id != project_id { - return Err(ErrorDto { - code: "FORBIDDEN".to_owned(), - message: "background task does not belong to requested project".to_owned(), - }); - } - Ok(()) -} - -fn background_error(err: BackgroundTaskPortError) -> ErrorDto { - let code = match &err { - BackgroundTaskPortError::NotFound => "NOT_FOUND", - BackgroundTaskPortError::AlreadyExists | BackgroundTaskPortError::Invalid(_) => "INVALID", - BackgroundTaskPortError::Runner(_) => "PROCESS", - BackgroundTaskPortError::Store(_) => "STORE", - }; - ErrorDto { - code: code.to_owned(), - message: err.to_string(), - } -} - -async fn resolve_project_readonly(project_id: &str, state: &AppState) -> Result { - let id = parse_project_id(project_id)?; - state - .open_project - .execute(OpenProjectInput { project_id: id }) - .await - .map(|output| output.project) - .map_err(ErrorDto::from) -} - -fn optional_request(args: &Value) -> Result, ErrorDto> -where - T: for<'de> Deserialize<'de>, -{ - match args { - Value::Object(map) => match map.get("request") { - Some(value) => serde_json::from_value(value.clone()) - .map(Some) - .map_err(invalid_args_error), - None if map.is_empty() => Ok(None), - None => serde_json::from_value(args.clone()) - .map(Some) - .map_err(invalid_args_error), - }, - Value::Null => Ok(None), - _ => Err(ErrorDto { - code: "INVALID".to_owned(), - message: "args must be an object".to_owned(), - }), - } -} - -fn validate_request_origin( - headers: &HeaderMap, - config: &ServerConfig, -) -> Result, Box>> { - let Some(origin) = headers.get(ORIGIN).and_then(|value| value.to_str().ok()) else { - return Err(Box::new(error_response( - StatusCode::FORBIDDEN, - "FORBIDDEN", - "missing Origin header", - None, - ))); - }; - - if origin_allowed(origin, config) { - Ok(Some(origin.to_owned())) - } else { - Err(Box::new(error_response( - StatusCode::FORBIDDEN, - "FORBIDDEN", - "origin not allowed", - None, - ))) - } -} - -fn request_origin(headers: &HeaderMap) -> Option { - headers - .get(ORIGIN) - .and_then(|value| value.to_str().ok()) - .map(str::to_owned) -} - -fn origin_allowed(origin: &str, config: &ServerConfig) -> bool { - if let Some(public_origin) = &config.public_origin { - return origin == public_origin; - } - - if !config.listen.ip().is_loopback() { - return false; - } - - let port = config.listen.port(); - origin == format!("http://127.0.0.1:{port}") - || origin == format!("http://localhost:{port}") - || origin == format!("http://[::1]:{port}") -} - -fn has_forbidden_query_secret(uri: &Uri) -> bool { - uri.query().is_some_and(|query| { - query.split('&').any(|pair| { - let key = pair.split_once('=').map_or(pair, |(key, _)| key); - matches!( - key.to_ascii_lowercase().as_str(), - "token" | "secret" | "session" | "code" - ) - }) - }) -} - -fn session_cookie(headers: &HeaderMap) -> Option { - headers - .get(COOKIE) - .and_then(|value| value.to_str().ok()) - .and_then(|raw| { - raw.split(';').find_map(|part| { - let (name, value) = part.trim().split_once('=')?; - (name == SESSION_COOKIE).then(|| value.to_owned()) - }) - }) -} - -fn json_response( - status: StatusCode, - value: &Value, - origin: Option<&str>, -) -> Response { - let body = serde_json::to_vec(value).expect("JSON value serializes"); - let mut response = Response::new(Full::new(Bytes::from(body))); - *response.status_mut() = status; - response - .headers_mut() - .insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); - add_cors_headers(&mut response, origin); - response -} - -fn error_dto_response( - status: StatusCode, - error: ErrorDto, - origin: Option<&str>, -) -> Response { - let value = serde_json::to_value(error).expect("ErrorDto serializes"); - json_response(status, &value, origin) -} - -fn error_response( - status: StatusCode, - code: impl Into, - message: impl Into, - origin: Option<&str>, -) -> Response { - error_dto_response( - status, - ErrorDto { - code: code.into(), - message: message.into(), - }, - origin, - ) -} - -fn cors_response(status: StatusCode, origin: Option<&str>) -> Response { - let mut response = Response::new(Full::new(Bytes::new())); - *response.status_mut() = status; - add_cors_headers(&mut response, origin); - response -} - -fn add_cors_headers(response: &mut Response, origin: Option<&str>) { - if let Some(origin) = origin { - if let Ok(origin) = HeaderValue::from_str(origin) { - response - .headers_mut() - .insert("access-control-allow-origin", origin); - response.headers_mut().insert( - "access-control-allow-credentials", - HeaderValue::from_static("true"), - ); - response.headers_mut().insert( - "access-control-allow-headers", - HeaderValue::from_static("content-type"), - ); - response.headers_mut().insert( - "access-control-allow-methods", - HeaderValue::from_static("POST, OPTIONS"), - ); - } - } -} - -fn status_for_error(error: &ErrorDto) -> StatusCode { - match error.code.as_str() { - "UNKNOWN_COMMAND" => StatusCode::BAD_REQUEST, - "INVALID" => StatusCode::BAD_REQUEST, - "NOT_FOUND" => StatusCode::NOT_FOUND, - "FORBIDDEN" => StatusCode::FORBIDDEN, - "UNAUTHORIZED" => StatusCode::UNAUTHORIZED, - _ => StatusCode::INTERNAL_SERVER_ERROR, - } -} - -fn validate_origin(origin: &str) -> Result<(), String> { - if origin == "*" || origin.contains('*') { - return Err("--public-origin must be exact, not a wildcard".to_owned()); - } - if origin.ends_with('/') { - return Err("--public-origin must not end with '/'".to_owned()); - } - if !(origin.starts_with("https://") || origin.starts_with("http://")) { - return Err("--public-origin must be an HTTP(S) origin".to_owned()); - } - Ok(()) -} - -fn default_app_data_dir() -> PathBuf { - if let Some(path) = env::var_os("IDEA_APP_DATA_DIR") { - return PathBuf::from(path); - } - if let Some(path) = env::var_os("XDG_DATA_HOME") { - return PathBuf::from(path).join("app.idea.ide"); - } - if let Some(home) = env::var_os("HOME") { - return PathBuf::from(home).join(".local/share/app.idea.ide"); - } - env::current_dir() - .unwrap_or_else(|_| PathBuf::from(".")) - .join(".ideai/app-data") -} - -fn new_pairing_code() -> String { - Uuid::new_v4() - .simple() - .to_string() - .chars() - .take(8) - .collect::() - .to_ascii_uppercase() -} - -fn new_session_token() -> String { - format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple()) -} - -fn invalid_args_error(err: serde_json::Error) -> ErrorDto { - ErrorDto { - code: "INVALID".to_owned(), - message: format!("invalid command args: {err}"), - } -} - -fn serialization_error(err: serde_json::Error) -> ErrorDto { - ErrorDto { - code: "SERIALIZATION".to_owned(), - message: format!("failed to serialize response: {err}"), - } -} - -#[derive(Debug, Clone, Deserialize, Serialize)] -struct ClientFrame { - id: String, - kind: String, - #[serde(default)] - payload: Value, -} - -#[derive(Debug, Clone, Serialize)] -struct ServerFrame { - kind: String, - #[serde(skip_serializing_if = "Option::is_none", rename = "replyTo")] - reply_to: Option, - payload: Value, -} - -impl ServerFrame { - fn attached( - request_id: &str, - session: &TerminalSessionDto, - scrollback: Vec, - next_seq: u64, - gap: bool, - ) -> Self { - let scrollback = if scrollback.is_empty() { - Vec::new() - } else { - vec![json!({ - "seq": 0, - "bytesBase64": base64_encode(&scrollback), - })] - }; - Self { - kind: "terminal.attached".to_owned(), - reply_to: Some(request_id.to_owned()), - payload: json!({ - "session": { - "sessionId": session.session_id, - "rows": session.rows, - "cols": session.cols, - }, - "scrollback": scrollback, - "nextSeq": next_seq, - "status": "running", - "gap": gap, - "assignedConversationId": session.assigned_conversation_id, - }), - } - } - - fn output(session_id: SessionId, seq: u64, bytes: Vec) -> Self { - Self { - kind: "terminal.output".to_owned(), - reply_to: None, - payload: json!({ - "sessionId": session_id.to_string(), - "seq": seq, - "bytesBase64": base64_encode(&bytes), - }), - } - } - - fn status(session_id: SessionId, status: &str, exit_code: Option) -> Self { - Self { - kind: "terminal.status".to_owned(), - reply_to: None, - payload: json!({ - "sessionId": session_id.to_string(), - "status": status, - "exitCode": exit_code, - }), - } - } - - fn domain_event(event: &DomainEvent) -> Self { - Self { - kind: "event.domain".to_owned(), - reply_to: None, - payload: serde_json::to_value(DomainEventDto::from(event)) - .expect("DomainEventDto serializes"), - } - } - - fn error( - request_id: Option, - session_id: Option, - code: impl Into, - message: impl Into, - ) -> Self { - Self { - kind: "error".to_owned(), - reply_to: request_id, - payload: json!({ - "sessionId": session_id, - "code": code.into(), - "message": message.into(), - }), - } - } - - fn pong() -> Self { - Self { - kind: "pong".to_owned(), - reply_to: None, - payload: json!({}), - } - } -} - -struct WsPtySink { - session_id: SessionId, - tx: mpsc::Sender, - seq: AtomicU64, -} - -impl WsPtySink { - fn new(session_id: SessionId, tx: mpsc::Sender, next_seq: u64) -> Self { - Self { - session_id, - tx, - seq: AtomicU64::new(next_seq), - } - } -} - -impl OutputSink for WsPtySink { - fn send(&self, item: PtyChunk) -> Result<(), OutputSinkError> { - let seq = self.seq.fetch_add(1, Ordering::Relaxed); - self.tx - .try_send(ServerFrame::output(self.session_id, seq, item)) - .map_err(|err| match err { - mpsc::error::TrySendError::Full(_) => OutputSinkError::Full, - mpsc::error::TrySendError::Closed(_) => OutputSinkError::Closed, - }) - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum WsOpcode { - Text, - Binary, - Close, - Ping, - Pong, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct WsFrame { - opcode: WsOpcode, - payload: Vec, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -enum WsFrameError { - Incomplete, - Protocol(String), - TooLarge, -} - -impl std::fmt::Display for WsFrameError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::Incomplete => f.write_str("incomplete websocket frame"), - Self::Protocol(message) => write!(f, "websocket protocol error: {message}"), - Self::TooLarge => f.write_str("websocket frame payload too large"), - } - } -} - -async fn read_ws_frame(reader: &mut R) -> Result -where - R: AsyncRead + Unpin, -{ - let mut header = [0_u8; 2]; - reader - .read_exact(&mut header) - .await - .map_err(|_| WsFrameError::Incomplete)?; - let len_code = header[1] & 0x7f; - let masked = header[1] & 0x80 != 0; - let mut rest = Vec::new(); - let extra_len = match len_code { - 126 => 2, - 127 => 8, - _ => 0, - }; - rest.resize(extra_len + usize::from(masked) * 4, 0); - if !rest.is_empty() { - reader - .read_exact(&mut rest) - .await - .map_err(|_| WsFrameError::Incomplete)?; - } - let mut raw = Vec::with_capacity(2 + rest.len()); - raw.extend_from_slice(&header); - raw.extend_from_slice(&rest); - let payload_len = decoded_payload_len(&raw)?; - if payload_len > WS_MAX_PAYLOAD { - return Err(WsFrameError::TooLarge); - } - let mut payload = vec![0_u8; payload_len]; - reader - .read_exact(&mut payload) - .await - .map_err(|_| WsFrameError::Incomplete)?; - raw.extend_from_slice(&payload); - decode_client_ws_frame(&raw) -} - -fn decode_client_ws_frame(raw: &[u8]) -> Result { - if raw.len() < 2 { - return Err(WsFrameError::Incomplete); - } - let fin = raw[0] & 0x80 != 0; - if !fin { - return Err(WsFrameError::Protocol( - "fragmented frames are not supported".to_owned(), - )); - } - let opcode = match raw[0] & 0x0f { - 0x1 => WsOpcode::Text, - 0x2 => WsOpcode::Binary, - 0x8 => WsOpcode::Close, - 0x9 => WsOpcode::Ping, - 0xA => WsOpcode::Pong, - other => { - return Err(WsFrameError::Protocol(format!( - "unsupported opcode {other}" - ))) - } - }; - let masked = raw[1] & 0x80 != 0; - if !masked { - return Err(WsFrameError::Protocol( - "client frames must be masked".to_owned(), - )); - } - let len_code = raw[1] & 0x7f; - let mut offset = 2; - let len = match len_code { - 126 => { - if raw.len() < offset + 2 { - return Err(WsFrameError::Incomplete); - } - let len = u16::from_be_bytes([raw[offset], raw[offset + 1]]) as usize; - offset += 2; - len - } - 127 => { - if raw.len() < offset + 8 { - return Err(WsFrameError::Incomplete); - } - let len = u64::from_be_bytes(raw[offset..offset + 8].try_into().expect("8 bytes")); - offset += 8; - usize::try_from(len).map_err(|_| WsFrameError::TooLarge)? - } - len => usize::from(len), - }; - if len > WS_MAX_PAYLOAD { - return Err(WsFrameError::TooLarge); - } - if raw.len() < offset + 4 + len { - return Err(WsFrameError::Incomplete); - } - let mask = &raw[offset..offset + 4]; - offset += 4; - let mut payload = raw[offset..offset + len].to_vec(); - for (i, byte) in payload.iter_mut().enumerate() { - *byte ^= mask[i % 4]; - } - Ok(WsFrame { opcode, payload }) -} - -fn decoded_payload_len(raw_header: &[u8]) -> Result { - let len_code = raw_header[1] & 0x7f; - let offset = 2; - let len = match len_code { - 126 => { - if raw_header.len() < offset + 2 { - return Err(WsFrameError::Incomplete); - } - u16::from_be_bytes([raw_header[offset], raw_header[offset + 1]]) as usize - } - 127 => { - if raw_header.len() < offset + 8 { - return Err(WsFrameError::Incomplete); - } - let len = - u64::from_be_bytes(raw_header[offset..offset + 8].try_into().expect("8 bytes")); - usize::try_from(len).map_err(|_| WsFrameError::TooLarge)? - } - len => usize::from(len), - }; - Ok(len) -} - -fn encode_ws_frame(opcode: WsOpcode, payload: &[u8]) -> Vec { - let opcode = match opcode { - WsOpcode::Text => 0x1, - WsOpcode::Binary => 0x2, - WsOpcode::Close => 0x8, - WsOpcode::Ping => 0x9, - WsOpcode::Pong => 0xA, - }; - let mut out = vec![0x80 | opcode]; - match payload.len() { - 0..=125 => out.push(payload.len() as u8), - 126..=65535 => { - out.push(126); - out.extend_from_slice(&(payload.len() as u16).to_be_bytes()); - } - len => { - out.push(127); - out.extend_from_slice(&(len as u64).to_be_bytes()); - } - } - out.extend_from_slice(payload); - out -} - -fn websocket_accept(key: &str) -> String { - let mut input = Vec::with_capacity(key.len() + WS_MAGIC.len()); - input.extend_from_slice(key.as_bytes()); - input.extend_from_slice(WS_MAGIC.as_bytes()); - base64_encode(&sha1_digest(&input)) -} - -fn base64_encode(bytes: &[u8]) -> String { - use base64::Engine as _; - base64::engine::general_purpose::STANDARD.encode(bytes) -} - -fn base64_decode(raw: &str) -> Result, ErrorDto> { - use base64::Engine as _; - base64::engine::general_purpose::STANDARD - .decode(raw) - .map_err(|err| ErrorDto { - code: "INVALID".to_owned(), - message: format!("invalid base64 bytes: {err}"), - }) -} - -fn sha1_digest(input: &[u8]) -> [u8; 20] { - let mut h0: u32 = 0x67452301; - let mut h1: u32 = 0xEFCDAB89; - let mut h2: u32 = 0x98BADCFE; - let mut h3: u32 = 0x10325476; - let mut h4: u32 = 0xC3D2E1F0; - - let bit_len = (input.len() as u64) * 8; - let mut msg = input.to_vec(); - msg.push(0x80); - while (msg.len() % 64) != 56 { - msg.push(0); - } - msg.extend_from_slice(&bit_len.to_be_bytes()); - - for chunk in msg.chunks(64) { - let mut w = [0_u32; 80]; - for (i, word) in w.iter_mut().take(16).enumerate() { - let j = i * 4; - *word = u32::from_be_bytes([chunk[j], chunk[j + 1], chunk[j + 2], chunk[j + 3]]); - } - for i in 16..80 { - w[i] = (w[i - 3] ^ w[i - 8] ^ w[i - 14] ^ w[i - 16]).rotate_left(1); - } - let mut a = h0; - let mut b = h1; - let mut c = h2; - let mut d = h3; - let mut e = h4; - for (i, word) in w.iter().enumerate() { - let (f, k) = match i { - 0..=19 => ((b & c) | ((!b) & d), 0x5A827999), - 20..=39 => (b ^ c ^ d, 0x6ED9EBA1), - 40..=59 => ((b & c) | (b & d) | (c & d), 0x8F1BBCDC), - _ => (b ^ c ^ d, 0xCA62C1D6), - }; - let temp = a - .rotate_left(5) - .wrapping_add(f) - .wrapping_add(e) - .wrapping_add(k) - .wrapping_add(*word); - e = d; - d = c; - c = b.rotate_left(30); - b = a; - a = temp; - } - h0 = h0.wrapping_add(a); - h1 = h1.wrapping_add(b); - h2 = h2.wrapping_add(c); - h3 = h3.wrapping_add(d); - h4 = h4.wrapping_add(e); - } - - let mut out = [0_u8; 20]; - out[0..4].copy_from_slice(&h0.to_be_bytes()); - out[4..8].copy_from_slice(&h1.to_be_bytes()); - out[8..12].copy_from_slice(&h2.to_be_bytes()); - out[12..16].copy_from_slice(&h3.to_be_bytes()); - out[16..20].copy_from_slice(&h4.to_be_bytes()); - out -} - -fn payload_string(payload: &Value, key: &str) -> Result { - payload - .get(key) - .and_then(Value::as_str) - .map(ToOwned::to_owned) - .ok_or_else(|| ErrorDto { - code: "INVALID".to_owned(), - message: format!("payload requires {key}"), - }) -} - -fn payload_u16(payload: &Value, key: &str) -> Option { - payload - .get(key) - .and_then(Value::as_u64) - .and_then(|value| u16::try_from(value).ok()) -} - -fn payload_bytes(payload: &Value) -> Result, ErrorDto> { - if let Some(raw) = payload.get("bytesBase64").and_then(Value::as_str) { - base64_decode(raw) - } else if let Some(raw) = payload.get("bytes").and_then(Value::as_str) { - base64_decode(raw) - } else { - Err(ErrorDto { - code: "INVALID".to_owned(), - message: "payload requires bytesBase64".to_owned(), - }) - } -} - -fn payload_session_id(payload: &Value) -> Option { - payload - .get("sessionId") - .and_then(Value::as_str) - .map(ToOwned::to_owned) -} - -#[cfg(test)] -mod tests { - use super::*; - use application::{ - CreateAgentInput, CreateProjectInput, LaunchAgentOutput, SaveProfileInput, - StructuredSessionDescriptor, - }; - use domain::ports::EventBus; - use domain::{ - AgentId, AgentProfile, BackgroundTask, BackgroundTaskKind, BackgroundTaskWakePolicy, - ContextInjection, NodeId, ProfileId, ProjectId, ProjectPath, PtySize, SessionKind, - SessionStatus, SessionStrategy, TaskId, TerminalSession, - }; - use http::header::HeaderName; - use std::time::Duration; - - static ENV_LOCK: Mutex<()> = Mutex::new(()); - - struct EnvVarGuard { - saved: Vec<(&'static str, Option)>, - } - - impl EnvVarGuard { - fn new(keys: &[&'static str]) -> Self { - Self { - saved: keys - .iter() - .map(|key| (*key, std::env::var_os(key))) - .collect(), - } - } - } - - impl Drop for EnvVarGuard { - fn drop(&mut self) { - for (key, value) in self.saved.iter().rev() { - if let Some(value) = value { - std::env::set_var(key, value); - } else { - std::env::remove_var(key); - } - } - } - } - - #[derive(Default)] - struct RecordingSecurityLogger { - events: Mutex>, - } - - impl RecordingSecurityLogger { - fn events(&self) -> Vec { - self.events.lock().unwrap().clone() - } - } - - impl SecurityLogger for RecordingSecurityLogger { - fn log(&self, event: SecurityLogEvent) { - self.events.lock().unwrap().push(event); - } - } - - fn create_web_root() -> PathBuf { - let root = std::env::temp_dir().join(format!("idea-web-root-{}", Uuid::new_v4())); - std::fs::create_dir_all(root.join("assets")).unwrap(); - std::fs::write( - root.join("index.html"), - r#"
"#, - ) - .unwrap(); - std::fs::write(root.join("assets").join("app.js"), "console.log('idea');").unwrap(); - std::fs::write(root.join("assets").join("style.css"), "body{margin:0}").unwrap(); - root - } - - fn test_config() -> ServerConfig { - ServerConfig { - listen: "127.0.0.1:17373".parse().unwrap(), - public_origin: None, - allow_remote: false, - trust_reverse_proxy: false, - app_data_dir: std::env::temp_dir().join(format!("idea-server-test-{}", Uuid::new_v4())), - web_root: create_web_root(), - } - } - - #[test] - fn default_app_data_dir_uses_tauri_identifier_with_env_precedence() { - let _lock = ENV_LOCK.lock().unwrap(); - let _guard = EnvVarGuard::new(&["IDEA_APP_DATA_DIR", "XDG_DATA_HOME", "HOME"]); - let root = std::env::temp_dir().join(format!("idea-app-data-env-{}", Uuid::new_v4())); - let home = root.join("home"); - let xdg = root.join("xdg"); - let override_dir = root.join("override"); - - std::env::remove_var("IDEA_APP_DATA_DIR"); - std::env::remove_var("XDG_DATA_HOME"); - std::env::set_var("HOME", &home); - assert_eq!( - default_app_data_dir(), - home.join(".local/share/app.idea.ide") - ); - - std::env::set_var("XDG_DATA_HOME", &xdg); - assert_eq!(default_app_data_dir(), xdg.join("app.idea.ide")); - - std::env::set_var("IDEA_APP_DATA_DIR", &override_dir); - assert_eq!(default_app_data_dir(), override_dir); - } - - fn state() -> Arc { - Arc::new(ServerState::new_for_test(test_config(), "PAIR1234")) - } - - async fn request( - state: Arc, - method: Method, - uri: &str, - body: Value, - extra_headers: &[(&str, &str)], - ) -> Response { - let mut builder = Request::builder() - .method(method) - .uri(uri) - .header(ORIGIN, "http://127.0.0.1:17373") - .header(CONTENT_TYPE, "application/json"); - for (name, value) in extra_headers { - builder = builder.header(HeaderName::from_bytes(name.as_bytes()).unwrap(), *value); - } - handle_request( - builder - .body(Full::new(Bytes::from(serde_json::to_vec(&body).unwrap()))) - .unwrap(), - state, - ) - .await - } - - async fn response_json(response: Response) -> (StatusCode, Value, HeaderMap) { - let status = response.status(); - let headers = response.headers().clone(); - let body = response.into_body().collect().await.unwrap().to_bytes(); - let value = if body.is_empty() { - Value::Null - } else { - serde_json::from_slice(&body).unwrap() - }; - (status, value, headers) - } - - async fn response_bytes(response: Response) -> (StatusCode, Bytes, HeaderMap) { - let status = response.status(); - let headers = response.headers().clone(); - let body = response.into_body().collect().await.unwrap().to_bytes(); - (status, body, headers) - } - - async fn pair_and_cookie(state: Arc) -> String { - let response = request( - state, - Method::POST, - "/api/pair", - json!({ "code": "PAIR1234" }), - &[], - ) - .await; - response - .headers() - .get(SET_COOKIE) - .unwrap() - .to_str() - .unwrap() - .split(';') - .next() - .unwrap() - .to_owned() - } - - async fn create_project_for_test(state: &Arc, name: &str) -> String { - let root = std::env::temp_dir() - .join(format!("idea-server-project-{}", Uuid::new_v4())) - .to_string_lossy() - .into_owned(); - let output = state - .app - .create_project - .execute(CreateProjectInput { - name: name.to_owned(), - root, - remote: None, - default_profile_id: None, - }) - .await - .expect("test project is created"); - output.project.id.to_string() - } - - async fn create_background_task_for_test( - state: &Arc, - project_id: &str, - label: &str, - ) -> (TaskId, AgentId) { - let task_id = TaskId::from_uuid(Uuid::new_v4()); - let owner = AgentId::from_uuid(Uuid::new_v4()); - let project_id = ProjectId::from_uuid(Uuid::parse_str(project_id).unwrap()); - let task = BackgroundTask::new( - task_id, - project_id, - owner, - BackgroundTaskKind::Command { - label: label.to_owned(), - }, - BackgroundTaskWakePolicy::WakeOwner, - 1_000, - None, - ) - .expect("test background task is valid"); - state - .app - .background_task_store - .create(&task) - .await - .expect("test background task is stored"); - (task_id, owner) - } - - async fn create_raw_cli_agent_for_test( - state: &Arc, - name: &str, - ) -> (String, String) { - let project_id = create_project_for_test(state, name).await; - let project = resolve_project_readonly(&project_id, &state.app) - .await - .expect("test project resolves"); - let profile_id = ProfileId::from_uuid(Uuid::new_v4()); - let profile = AgentProfile::new( - profile_id, - format!("{name} shell profile"), - "/bin/sh", - vec![ - "-c".to_owned(), - "printf agent-ready; sleep 30".to_owned(), - "idea-test-sh".to_owned(), - ], - ContextInjection::env("IDEA_CONTEXT").expect("valid env injection"), - None, - "{agentRunDir}", - Some( - SessionStrategy::new(Some("--session-id".to_owned()), "--resume") - .expect("valid session strategy"), - ), - ) - .expect("valid raw CLI profile"); - state - .app - .save_profile - .execute(SaveProfileInput { profile }) - .await - .expect("test profile saved"); - let agent = state - .app - .create_agent - .execute(CreateAgentInput { - project, - name: format!("{name} agent"), - profile_id, - initial_content: Some("Test agent context".to_owned()), - }) - .await - .expect("test agent created") - .agent; - (project_id, agent.id.to_string()) - } - - fn ws_headers(origin: &str, cookie: Option<&str>) -> HeaderMap { - let mut headers = HeaderMap::new(); - headers.insert(ORIGIN, HeaderValue::from_str(origin).unwrap()); - headers.insert("upgrade", HeaderValue::from_static("websocket")); - headers.insert("connection", HeaderValue::from_static("Upgrade")); - headers.insert( - "sec-websocket-key", - HeaderValue::from_static("dGhlIHNhbXBsZSBub25jZQ=="), - ); - if let Some(cookie) = cookie { - headers.insert(COOKIE, HeaderValue::from_str(cookie).unwrap()); - } - headers - } - - fn masked_text_frame(text: &str) -> Vec { - let payload = text.as_bytes(); - let mask = [1_u8, 2, 3, 4]; - let mut out = vec![0x81]; - out.push(0x80 | payload.len() as u8); - out.extend_from_slice(&mask); - for (i, byte) in payload.iter().enumerate() { - out.push(byte ^ mask[i % 4]); - } - out - } - - async fn recv_server_frame(rx: &mut mpsc::Receiver) -> ServerFrame { - tokio::time::timeout(Duration::from_secs(2), rx.recv()) - .await - .expect("server frame received before timeout") - .expect("server frame channel is open") - } - - async fn recv_server_frame_where( - rx: &mut mpsc::Receiver, - mut predicate: impl FnMut(&ServerFrame) -> bool, - ) -> ServerFrame { - for _ in 0..16 { - let frame = recv_server_frame(rx).await; - if predicate(&frame) { - return frame; - } - } - panic!("matching server frame was not received"); - } - - fn drain_server_frames(rx: &mut mpsc::Receiver) { - while rx.try_recv().is_ok() {} - } - - fn ws_frame(id: &str, kind: &str, payload: Value) -> ClientFrame { - ClientFrame { - id: id.to_owned(), - kind: kind.to_owned(), - payload, - } - } - - fn attached_session_id(frame: &ServerFrame) -> String { - assert_eq!(frame.kind, "terminal.attached"); - frame.payload["session"]["sessionId"] - .as_str() - .expect("attached frame carries sessionId") - .to_owned() - } - - fn open_terminal_payload(command: &str, args: Vec<&str>) -> Value { - json!({ - "request": { - "cwd": std::env::temp_dir().to_string_lossy(), - "rows": 24, - "cols": 80, - "command": command, - "args": args, - } - }) - } - - fn agent_launch_payload( - project_id: &str, - agent_id: &str, - node_id: &str, - conversation_id: Option<&str>, - ) -> Value { - json!({ - "projectId": project_id, - "agentId": agent_id, - "nodeId": node_id, - "rows": 24, - "cols": 80, - "conversationId": conversation_id, - }) - } - - async fn close_ws_session(state: &Arc, session_id: &str) { - let (tx, mut rx) = mpsc::channel(8); - let owned = Arc::new(Mutex::new(Vec::new())); - handle_client_frame( - ws_frame( - "close-cleanup", - "terminal.close", - json!({ "sessionId": session_id }), - ), - state, - &tx, - &owned, - ) - .await; - let _ = recv_server_frame(&mut rx).await; - } - - async fn wait_for_scrollback( - state: &Arc, - session_id: &str, - needle: &[u8], - ) -> Vec { - let sid = parse_session_id(session_id).expect("test session id parses"); - let handle = PtyHandle { session_id: sid }; - for _ in 0..100 { - if let Ok(scrollback) = state.app.pty_port.scrollback(&handle) { - if scrollback - .windows(needle.len()) - .any(|window| window == needle) - { - return scrollback; - } - } - tokio::time::sleep(Duration::from_millis(20)).await; - } - panic!( - "scrollback did not contain {:?}", - String::from_utf8_lossy(needle) - ); - } - - #[test] - fn websocket_accept_matches_rfc_example() { - assert_eq!( - websocket_accept("dGhlIHNhbXBsZSBub25jZQ=="), - "s3pPLMBiTxaQ9kYGzzhZRbK+xOo=" - ); - } - - #[test] - fn websocket_upgrade_requires_valid_cookie() { - let state = state(); - let headers = ws_headers("http://127.0.0.1:17373", None); - - let response = validate_ws_upgrade(&headers, &state).unwrap_err(); - - assert_eq!(response.status(), StatusCode::UNAUTHORIZED); - } - - #[test] - fn websocket_upgrade_rejects_invalid_cookie() { - let state = state(); - let headers = ws_headers( - "http://127.0.0.1:17373", - Some("idea_session=not-a-valid-session"), - ); - - let response = validate_ws_upgrade(&headers, &state).unwrap_err(); - - assert_eq!(response.status(), StatusCode::UNAUTHORIZED); - } - - #[test] - fn websocket_upgrade_requires_allowed_origin() { - let state = state(); - let token = state.create_session(); - let headers = ws_headers( - "https://evil.example", - Some(&format!("idea_session={token}")), - ); - - let response = validate_ws_upgrade(&headers, &state).unwrap_err(); - - assert_eq!(response.status(), StatusCode::FORBIDDEN); - } - - #[test] - fn websocket_upgrade_accepts_valid_cookie_and_origin() { - let state = state(); - let token = state.create_session(); - let headers = ws_headers( - "http://127.0.0.1:17373", - Some(&format!("idea_session={token}")), - ); - - let accept = validate_ws_upgrade(&headers, &state).unwrap(); - - assert_eq!(accept, "s3pPLMBiTxaQ9kYGzzhZRbK+xOo="); - } - - #[test] - fn websocket_client_text_frame_decodes_masked_json() { - let raw = masked_text_frame(r#"{"kind":"ping"}"#); - - let frame = decode_client_ws_frame(&raw).unwrap(); - - assert_eq!(frame.opcode, WsOpcode::Text); - assert_eq!(frame.payload, br#"{"kind":"ping"}"#); - } - - #[test] - fn websocket_client_json_frame_round_trips_base64_bytes() { - let sid = domain::SessionId::new_random().to_string(); - let value = json!({ - "id": "req-1", - "kind": "input", - "payload": { - "sessionId": sid, - "bytesBase64": "AQID" - } - }); - - let frame: ClientFrame = serde_json::from_value(value.clone()).unwrap(); - let roundtrip = serde_json::to_value(&frame).unwrap(); - - assert_eq!(roundtrip, value); - assert_eq!(payload_bytes(&frame.payload).unwrap(), vec![1, 2, 3]); - } - - #[test] - fn websocket_server_attached_frame_uses_contract_shape() { - let sid = domain::SessionId::new_random().to_string(); - let frame = ServerFrame::attached( - "req-attach", - &TerminalSessionDto { - session_id: sid.clone(), - cwd: "/tmp".to_owned(), - rows: 24, - cols: 80, - assigned_conversation_id: None, - engine_session_id: None, - cell_kind: crate::dto::CellKind::Pty, - }, - vec![4, 5, 6], - 1, - true, - ); - let value = serde_json::to_value(frame).unwrap(); - - assert_eq!(value["kind"], "terminal.attached"); - assert_eq!(value["replyTo"], "req-attach"); - assert_eq!(value["payload"]["session"]["sessionId"], sid); - assert_eq!(value["payload"]["scrollback"][0]["seq"], 0); - assert_eq!(value["payload"]["scrollback"][0]["bytesBase64"], "BAUG"); - assert_eq!(value["payload"]["nextSeq"], 1); - assert_eq!(value["payload"]["gap"], true); - } - - #[tokio::test] - async fn websocket_app_ping_emits_pong() { - let state = state(); - let (tx, mut rx) = mpsc::channel(4); - let owned = Arc::new(Mutex::new(Vec::new())); - - handle_client_frame(ws_frame("ping-1", "ping", json!({})), &state, &tx, &owned).await; - let frame = recv_server_frame(&mut rx).await; - - assert_eq!(frame.kind, "pong"); - assert!(frame.payload.as_object().unwrap().is_empty()); - } - - #[test] - fn websocket_domain_event_frame_uses_contract_shape() { - let project_id = ProjectId::from_uuid(Uuid::new_v4()); - let frame = ServerFrame::domain_event(&DomainEvent::ProjectCreated { project_id }); - - assert_eq!(frame.kind, "event.domain"); - assert!(frame.reply_to.is_none()); - assert_eq!(frame.payload["type"], "projectCreated"); - assert_eq!(frame.payload["projectId"], project_id.to_string()); - } - - #[tokio::test] - async fn websocket_domain_event_relay_forwards_domain_events() { - let state = state(); - let project_id = ProjectId::from_uuid(Uuid::new_v4()); - let event_rx = state.app.event_bus.raw_receiver(); - let (tx, mut rx) = mpsc::channel(4); - let relay = spawn_ws_domain_event_relay(event_rx, tx); - - state - .app - .event_bus - .publish(DomainEvent::ProjectCreated { project_id }); - let frame = tokio::time::timeout(Duration::from_secs(1), rx.recv()) - .await - .expect("event frame is forwarded") - .expect("event frame channel is open"); - relay.abort(); - - assert_eq!(frame.kind, "event.domain"); - assert_eq!(frame.payload["type"], "projectCreated"); - assert_eq!(frame.payload["projectId"], project_id.to_string()); - } - - #[tokio::test] - async fn websocket_domain_event_relay_forwards_background_completion() { - let state = state(); - let project_id = ProjectId::from_uuid(Uuid::new_v4()); - let task_id = TaskId::from_uuid(Uuid::new_v4()); - let owner = AgentId::from_uuid(Uuid::new_v4()); - let event_rx = state.app.event_bus.raw_receiver(); - let (tx, mut rx) = mpsc::channel(4); - let relay = spawn_ws_domain_event_relay(event_rx, tx); - - state - .app - .event_bus - .publish(DomainEvent::BackgroundTaskCompleted { - project_id, - task_id, - owner_agent_id: owner, - }); - let frame = tokio::time::timeout(Duration::from_secs(1), rx.recv()) - .await - .expect("background completion event is forwarded") - .expect("event frame channel is open"); - relay.abort(); - - assert_eq!(frame.kind, "event.domain"); - assert_eq!(frame.payload["type"], "backgroundTaskChanged"); - assert_eq!(frame.payload["projectId"], project_id.to_string()); - assert_eq!(frame.payload["taskId"], task_id.to_string()); - assert_eq!(frame.payload["agentId"], owner.to_string()); - assert_eq!(frame.payload["state"], "completed"); - } - - #[tokio::test] - async fn websocket_domain_event_relay_excludes_pty_output() { - let state = state(); - let project_id = ProjectId::from_uuid(Uuid::new_v4()); - let event_rx = state.app.event_bus.raw_receiver(); - let (tx, mut rx) = mpsc::channel(4); - let relay = spawn_ws_domain_event_relay(event_rx, tx); - - state.app.event_bus.publish(DomainEvent::PtyOutput { - session_id: SessionId::new_random(), - bytes: b"hidden from global live bus".to_vec(), - }); - state - .app - .event_bus - .publish(DomainEvent::ProjectCreated { project_id }); - let frame = tokio::time::timeout(Duration::from_secs(1), rx.recv()) - .await - .expect("sentinel event is forwarded") - .expect("event frame channel is open"); - relay.abort(); - - assert_eq!(frame.kind, "event.domain"); - assert_eq!(frame.payload["type"], "projectCreated"); - assert_eq!(frame.payload["projectId"], project_id.to_string()); - assert!( - rx.try_recv().is_err(), - "PtyOutput must not be emitted on the global event.domain stream" - ); - } - - #[tokio::test] - async fn websocket_domain_event_relay_drops_when_client_queue_is_full() { - let state = state(); - let event_rx = state.app.event_bus.raw_receiver(); - let (tx, mut rx) = mpsc::channel(1); - assert!(tx.try_send(ServerFrame::pong()).is_ok()); - let relay = spawn_ws_domain_event_relay(event_rx, tx); - - state.app.event_bus.publish(DomainEvent::ProjectCreated { - project_id: ProjectId::from_uuid(Uuid::new_v4()), - }); - tokio::time::sleep(Duration::from_millis(20)).await; - let first = rx.try_recv().expect("pre-filled frame remains queued"); - relay.abort(); - - assert_eq!(first.kind, "pong"); - assert!( - rx.try_recv().is_err(), - "full client queue should drop the live event instead of buffering" - ); - } - - #[tokio::test] - async fn websocket_open_terminal_emits_attached_with_empty_scrollback() { - let state = state(); - let (tx, mut rx) = mpsc::channel(16); - let owned = Arc::new(Mutex::new(Vec::new())); - - handle_client_frame( - ws_frame( - "open-1", - "terminal.open", - open_terminal_payload("/bin/sh", vec!["-c", "sleep 30"]), - ), - &state, - &tx, - &owned, - ) - .await; - let frame = recv_server_frame(&mut rx).await; - let session_id = attached_session_id(&frame); - - assert_eq!(frame.reply_to.as_deref(), Some("open-1")); - assert_eq!(frame.payload["scrollback"].as_array().unwrap().len(), 0); - assert_eq!(frame.payload["nextSeq"], 0); - - close_ws_session(&state, &session_id).await; - } - - #[tokio::test] - async fn websocket_attach_terminal_replays_scrollback_in_attached_ack() { - let state = state(); - let (tx, mut rx) = mpsc::channel(32); - let owned = Arc::new(Mutex::new(Vec::new())); - - handle_client_frame( - ws_frame( - "open-replay", - "terminal.open", - open_terminal_payload("/bin/sh", vec!["-c", "printf replay-ready; sleep 30"]), - ), - &state, - &tx, - &owned, - ) - .await; - let opened = recv_server_frame(&mut rx).await; - let session_id = attached_session_id(&opened); - - let scrollback = wait_for_scrollback(&state, &session_id, b"replay-ready").await; - assert!(scrollback.len() <= 100 * 1024); - drain_server_frames(&mut rx); - - handle_client_frame( - ws_frame( - "attach-replay", - "terminal.attach", - json!({ - "sessionId": session_id, - "lastSeq": 0, - "rows": 30, - "cols": 100, - }), - ), - &state, - &tx, - &owned, - ) - .await; - let attached = recv_server_frame(&mut rx).await; - let replay = attached.payload["scrollback"].as_array().unwrap(); - - assert_eq!(attached.kind, "terminal.attached"); - assert_eq!(attached.reply_to.as_deref(), Some("attach-replay")); - assert_eq!(attached.payload["session"]["rows"], 30); - assert_eq!(attached.payload["session"]["cols"], 100); - assert_eq!(attached.payload["gap"], true); - assert_eq!(attached.payload["nextSeq"], 1); - assert_eq!( - base64_decode(replay[0]["bytesBase64"].as_str().unwrap()).unwrap(), - scrollback - ); - - close_ws_session( - &state, - attached.payload["session"]["sessionId"].as_str().unwrap(), - ) - .await; - } - - #[tokio::test] - async fn websocket_close_terminal_emits_exited_status_and_releases_session() { - let state = state(); - let (tx, mut rx) = mpsc::channel(16); - let owned = Arc::new(Mutex::new(Vec::new())); - - handle_client_frame( - ws_frame( - "open-close", - "terminal.open", - open_terminal_payload("/bin/sh", vec!["-c", "sleep 30"]), - ), - &state, - &tx, - &owned, - ) - .await; - let opened = recv_server_frame(&mut rx).await; - let session_id = attached_session_id(&opened); - - handle_client_frame( - ws_frame( - "close-1", - "terminal.close", - json!({ "sessionId": session_id }), - ), - &state, - &tx, - &owned, - ) - .await; - let status = recv_server_frame(&mut rx).await; - - assert_eq!(status.kind, "terminal.status"); - assert_eq!(status.payload["status"], "exited"); - assert_eq!(status.payload["sessionId"], session_id); - assert_eq!(state.ws_pty_bridge.active_sessions(), 0); - assert!(state.app.terminal_sessions.is_empty()); - } - - #[tokio::test] - async fn websocket_launch_agent_emits_attached_with_assigned_conversation_id() { - let state = state(); - let (project_id, agent_id) = create_raw_cli_agent_for_test(&state, "ws-launch").await; - let node_id = Uuid::new_v4().to_string(); - let (tx, mut rx) = mpsc::channel(32); - let owned = Arc::new(Mutex::new(Vec::new())); - - handle_client_frame( - ws_frame( - "agent-launch-1", - "agent.launch", - agent_launch_payload(&project_id, &agent_id, &node_id, None), - ), - &state, - &tx, - &owned, - ) - .await; - let attached = recv_server_frame(&mut rx).await; - let session_id = attached_session_id(&attached); - - assert_eq!(attached.kind, "terminal.attached"); - assert_eq!(attached.reply_to.as_deref(), Some("agent-launch-1")); - assert_eq!(attached.payload["scrollback"].as_array().unwrap().len(), 0); - assert!(attached.payload["assignedConversationId"] - .as_str() - .and_then(|raw| Uuid::parse_str(raw).ok()) - .is_some()); - - close_ws_session(&state, &session_id).await; - } - - #[tokio::test] - async fn websocket_launch_agent_structured_is_unsupported() { - let state = state(); - let (tx, mut rx) = mpsc::channel(4); - let owned = Arc::new(Mutex::new(Vec::new())); - let session_id = SessionId::new_random(); - let agent_id = AgentId::from_uuid(Uuid::new_v4()); - let node_id = NodeId::from_uuid(Uuid::new_v4()); - let size = PtySize::new(24, 80).unwrap(); - let mut session = TerminalSession::starting( - session_id, - node_id, - ProjectPath::new("/".to_owned()).unwrap(), - SessionKind::Agent { agent_id }, - size, - ); - session.status = SessionStatus::Running; - let frame = ws_frame("agent-structured", "agent.launch", json!({})); - let output = LaunchAgentOutput { - session, - assigned_conversation_id: Some("pair-conversation".to_owned()), - engine_session_id: Some("engine-session".to_owned()), - structured: Some(StructuredSessionDescriptor { - session_id, - agent_id, - node_id, - conversation_id: Some("engine-session".to_owned()), - }), - profile: None, - }; - - if let Err(err) = send_launch_agent_attached(&frame, &state, &tx, &owned, output).await { - tx.send(ServerFrame::error( - Some(frame.id), - payload_session_id(&frame.payload), - err.code, - err.message, - )) - .await - .unwrap(); - } - let error = recv_server_frame(&mut rx).await; - - assert_eq!(error.kind, "error"); - assert_eq!(error.reply_to.as_deref(), Some("agent-structured")); - assert_eq!(error.payload["code"], "UNSUPPORTED"); - assert_eq!( - error.payload["message"], - "structured agent sessions do not stream over the PTY websocket" - ); - assert_eq!(state.ws_pty_bridge.active_sessions(), 0); - } - - #[tokio::test] - async fn websocket_launch_agent_reattach_replays_scrollback_without_respawn() { - let state = state(); - let (project_id, agent_id) = create_raw_cli_agent_for_test(&state, "ws-reattach").await; - let node_id = Uuid::new_v4().to_string(); - let (tx, mut rx) = mpsc::channel(32); - let owned = Arc::new(Mutex::new(Vec::new())); - - handle_client_frame( - ws_frame( - "agent-launch-reattach", - "agent.launch", - agent_launch_payload(&project_id, &agent_id, &node_id, None), - ), - &state, - &tx, - &owned, - ) - .await; - let launched = recv_server_frame(&mut rx).await; - let session_id = attached_session_id(&launched); - let scrollback = wait_for_scrollback(&state, &session_id, b"agent-ready").await; - drain_server_frames(&mut rx); - - handle_client_frame( - ws_frame( - "agent-attach", - "terminal.attach", - json!({ "sessionId": session_id, "lastSeq": 0, "rows": 24, "cols": 80 }), - ), - &state, - &tx, - &owned, - ) - .await; - let attached = recv_server_frame_where(&mut rx, |frame| { - frame.kind == "terminal.attached" && frame.reply_to.as_deref() == Some("agent-attach") - }) - .await; - let replay = attached.payload["scrollback"].as_array().unwrap(); - - assert_eq!(attached.kind, "terminal.attached"); - assert_eq!(attached.payload["session"]["sessionId"], session_id); - assert_eq!( - base64_decode(replay[0]["bytesBase64"].as_str().unwrap()).unwrap(), - scrollback - ); - let aid = parse_agent_id(&agent_id).unwrap(); - assert_eq!( - state.app.terminal_sessions.sessions_for_agent(&aid).len(), - 1 - ); - - close_ws_session(&state, &session_id).await; - } - - #[tokio::test] - async fn websocket_launch_agent_same_cell_is_idempotent_singleton() { - let state = state(); - let (project_id, agent_id) = create_raw_cli_agent_for_test(&state, "ws-singleton").await; - let node_id = Uuid::new_v4().to_string(); - let (tx, mut rx) = mpsc::channel(32); - let owned = Arc::new(Mutex::new(Vec::new())); - - handle_client_frame( - ws_frame( - "agent-launch-first", - "agent.launch", - agent_launch_payload(&project_id, &agent_id, &node_id, None), - ), - &state, - &tx, - &owned, - ) - .await; - let first = recv_server_frame(&mut rx).await; - let first_session = attached_session_id(&first); - - handle_client_frame( - ws_frame( - "agent-launch-second", - "agent.launch", - agent_launch_payload( - &project_id, - &agent_id, - &node_id, - first.payload["assignedConversationId"].as_str(), - ), - ), - &state, - &tx, - &owned, - ) - .await; - let second = recv_server_frame_where(&mut rx, |frame| { - frame.kind == "terminal.attached" - && frame.reply_to.as_deref() == Some("agent-launch-second") - }) - .await; - - assert_eq!(second.kind, "terminal.attached"); - assert_eq!(attached_session_id(&second), first_session); - let aid = parse_agent_id(&agent_id).unwrap(); - assert_eq!( - state.app.terminal_sessions.sessions_for_agent(&aid).len(), - 1 - ); - - close_ws_session(&state, &first_session).await; - } - - #[tokio::test] - async fn websocket_launch_agent_different_cell_is_refused_by_singleton_guard() { - let state = state(); - let (project_id, agent_id) = - create_raw_cli_agent_for_test(&state, "ws-singleton-refuse").await; - let first_node = Uuid::new_v4().to_string(); - let second_node = Uuid::new_v4().to_string(); - let (tx, mut rx) = mpsc::channel(32); - let owned = Arc::new(Mutex::new(Vec::new())); - - handle_client_frame( - ws_frame( - "agent-launch-first-cell", - "agent.launch", - agent_launch_payload(&project_id, &agent_id, &first_node, None), - ), - &state, - &tx, - &owned, - ) - .await; - let first = recv_server_frame(&mut rx).await; - let session_id = attached_session_id(&first); - - handle_client_frame( - ws_frame( - "agent-launch-other-cell", - "agent.launch", - agent_launch_payload(&project_id, &agent_id, &second_node, None), - ), - &state, - &tx, - &owned, - ) - .await; - let error = recv_server_frame_where(&mut rx, |frame| { - frame.kind == "error" && frame.reply_to.as_deref() == Some("agent-launch-other-cell") - }) - .await; - - assert_eq!(error.kind, "error"); - assert_eq!(error.reply_to.as_deref(), Some("agent-launch-other-cell")); - assert_eq!(error.payload["code"], "AGENT_ALREADY_RUNNING"); - let aid = parse_agent_id(&agent_id).unwrap(); - assert_eq!( - state.app.terminal_sessions.sessions_for_agent(&aid).len(), - 1 - ); - - close_ws_session(&state, &session_id).await; - } - - #[test] - fn websocket_client_unmasked_frame_is_rejected() { - let raw = [0x81, 0x02, b'h', b'i']; - - let err = decode_client_ws_frame(&raw).unwrap_err(); - - assert!(matches!(err, WsFrameError::Protocol(_))); - } - - #[test] - fn websocket_payload_too_large_is_rejected() { - let len = (WS_MAX_PAYLOAD + 1) as u64; - let mut raw = vec![0x81, 0x80 | 127]; - raw.extend_from_slice(&len.to_be_bytes()); - raw.extend_from_slice(&[1, 2, 3, 4]); - - let err = decode_client_ws_frame(&raw).unwrap_err(); - - assert_eq!(err, WsFrameError::TooLarge); - } - - #[test] - fn websocket_server_frames_are_unmasked() { - let raw = encode_ws_frame(WsOpcode::Text, b"{}"); - - assert_eq!(raw[0], 0x81); - assert_eq!(raw[1] & 0x80, 0, "server frames must not be masked"); - } - - #[tokio::test] - async fn websocket_sink_full_reports_backpressure_without_blocking() { - let (tx, mut rx) = mpsc::channel(1); - let sid = domain::SessionId::new_random(); - let sink = WsPtySink::new(sid, tx, 0); - - assert!(sink.send(vec![1]).is_ok()); - assert_eq!(sink.send(vec![2]), Err(OutputSinkError::Full)); - let frame = rx.recv().await.unwrap(); - assert_eq!(frame.kind, "terminal.output"); - } - - #[tokio::test] - async fn websocket_output_bridge_replaces_attachment_without_double_delivery() { - let bridge = OutputBridge::::new(); - let sid = domain::SessionId::new_random(); - let (old_tx, mut old_rx) = mpsc::channel(4); - let (new_tx, mut new_rx) = mpsc::channel(4); - - let old_gen = bridge.register(sid, Arc::new(WsPtySink::new(sid, old_tx, 0))); - let _new_gen = bridge.register(sid, Arc::new(WsPtySink::new(sid, new_tx, 0))); - - assert!(bridge.send_output(&sid, vec![9])); - assert!(old_rx.try_recv().is_err()); - let frame = new_rx.recv().await.unwrap(); - assert_eq!(frame.kind, "terminal.output"); - assert_eq!(frame.payload["bytesBase64"], "CQ=="); - - bridge.unregister_if(&sid, old_gen); - assert_eq!(bridge.active_sessions(), 1); - } - - #[test] - fn public_bind_requires_explicit_remote_security() { - let config = ServerConfig { - listen: "0.0.0.0:17373".parse().unwrap(), - ..test_config() - }; - - assert!(config.validate().is_err()); - - let config = ServerConfig { - allow_remote: true, - public_origin: Some("https://idea.example.com".to_owned()), - trust_reverse_proxy: true, - ..config - }; - assert!(config.validate().is_ok()); - } - - #[test] - fn allow_remote_requires_https_origin_and_reverse_proxy() { - let base = ServerConfig { - listen: "0.0.0.0:17373".parse().unwrap(), - allow_remote: true, - ..test_config() - }; - - assert!(base.validate().is_err()); - - let with_http = ServerConfig { - public_origin: Some("http://idea.example.com".to_owned()), - trust_reverse_proxy: true, - ..base.clone() - }; - assert!(with_http.validate().is_err()); - - let without_proxy = ServerConfig { - public_origin: Some("https://idea.example.com".to_owned()), - trust_reverse_proxy: false, - ..base - }; - assert!(without_proxy.validate().is_err()); - } - - #[tokio::test] - async fn invoke_requires_valid_cookie() { - let state = state(); - - let response = request( - state, - Method::POST, - "/api/invoke", - json!({ "command": "health", "args": {} }), - &[], - ) - .await; - let (status, body, _) = response_json(response).await; - - assert_eq!(status, StatusCode::UNAUTHORIZED); - assert_eq!(body["code"], "UNAUTHORIZED"); - } - - #[tokio::test] - async fn invoke_rejects_invalid_session_cookie() { - let state = state(); - - let response = request( - state, - Method::POST, - "/api/invoke", - json!({ "command": "health", "args": {} }), - &[("cookie", "idea_session=not-a-valid-session")], - ) - .await; - let (status, body, _) = response_json(response).await; - - assert_eq!(status, StatusCode::UNAUTHORIZED); - assert_eq!(body["code"], "UNAUTHORIZED"); - } - - #[tokio::test] - async fn logout_revokes_session_for_http_and_websocket() { - let state = state(); - let cookie = pair_and_cookie(Arc::clone(&state)).await; - - let response = request( - Arc::clone(&state), - Method::POST, - "/api/logout", - json!({}), - &[("cookie", &cookie)], - ) - .await; - let (status, body, headers) = response_json(response).await; - - assert_eq!(status, StatusCode::OK); - assert_eq!(body, json!({ "revoked": true })); - assert!(headers - .get(SET_COOKIE) - .unwrap() - .to_str() - .unwrap() - .contains("Max-Age=0")); - - let response = request( - Arc::clone(&state), - Method::POST, - "/api/invoke", - json!({ "command": "health", "args": {} }), - &[("cookie", &cookie)], - ) - .await; - let (status, body, _) = response_json(response).await; - assert_eq!(status, StatusCode::UNAUTHORIZED); - assert_eq!(body["code"], "UNAUTHORIZED"); - - let headers = ws_headers("http://127.0.0.1:17373", Some(&cookie)); - let response = validate_ws_upgrade(&headers, &state).unwrap_err(); - assert_eq!(response.status(), StatusCode::UNAUTHORIZED); - } - - #[tokio::test] - async fn security_logs_pairing_origin_ws_and_revoke_without_secrets() { - let logger = Arc::new(RecordingSecurityLogger::default()); - let state = Arc::new(ServerState::new_for_test_with_logger( - test_config(), - "PAIR1234", - logger.clone(), - )); - - let bad_origin = handle_request( - Request::builder() - .method(Method::POST) - .uri("/api/pair") - .header(ORIGIN, "https://evil.example") - .header(CONTENT_TYPE, "application/json") - .body(Full::new(Bytes::from_static(br#"{"code":"PAIR1234"}"#))) - .unwrap(), - Arc::clone(&state), - ) - .await; - assert_eq!(bad_origin.status(), StatusCode::FORBIDDEN); - - let wrong_code = request( - Arc::clone(&state), - Method::POST, - "/api/pair", - json!({ "code": "WRONG999" }), - &[], - ) - .await; - assert_eq!(wrong_code.status(), StatusCode::FORBIDDEN); - - let cookie = pair_and_cookie(Arc::clone(&state)).await; - let _ = request( - Arc::clone(&state), - Method::POST, - "/api/logout", - json!({}), - &[("cookie", &cookie)], - ) - .await; - - let headers = ws_headers( - "http://127.0.0.1:17373", - Some("idea_session=invalid-token-value"), - ); - let _ = validate_ws_upgrade(&headers, &state); - - let events = logger.events(); - assert!(events.iter().any(|event| matches!( - event, - SecurityLogEvent::OriginRejected { route, .. } if route == "/api/pair" - ))); - assert!(events.iter().any(|event| matches!( - event, - SecurityLogEvent::PairingFailed { - reason: "wrong_code", - .. - } - ))); - assert!(events - .iter() - .any(|event| matches!(event, SecurityLogEvent::PairingSucceeded { .. }))); - assert!(events - .iter() - .any(|event| matches!(event, SecurityLogEvent::SessionRevoked { .. }))); - assert!(events.iter().any(|event| matches!( - event, - SecurityLogEvent::WsUpgradeRejected { - reason: "invalid_session", - .. - } - ))); - - let lines = events.iter().map(security_log_line).collect::>(); - assert!( - !lines.iter().any(|line| line.contains("PAIR1234") - || line.contains("WRONG999") - || line.contains("invalid-token-value")), - "security logs must not leak pairing codes or session tokens" - ); - } - - #[tokio::test] - async fn pairing_rejects_wrong_code() { - let state = state(); - - let response = request( - state, - Method::POST, - "/api/pair", - json!({ "code": "WRONG999" }), - &[], - ) - .await; - let (status, body, headers) = response_json(response).await; - - assert_eq!(status, StatusCode::FORBIDDEN); - assert_eq!(body["code"], "FORBIDDEN"); - assert!( - !headers.contains_key(SET_COOKIE), - "wrong pairing code must not issue a session cookie" - ); - } - - #[tokio::test] - async fn pairing_sets_http_only_strict_cookie_for_loopback_dev() { - let state = state(); - - let response = request( - state, - Method::POST, - "/api/pair", - json!({ "code": "PAIR1234" }), - &[], - ) - .await; - let (status, body, headers) = response_json(response).await; - - assert_eq!(status, StatusCode::OK); - assert_eq!(body, json!({ "paired": true })); - let cookie = headers.get(SET_COOKIE).unwrap().to_str().unwrap(); - assert!(cookie.contains("idea_session=")); - assert!(cookie.contains("HttpOnly")); - assert!(cookie.contains("SameSite=Strict")); - assert!( - !cookie.contains("Secure"), - "loopback dev over HTTP deliberately avoids Secure so browsers store it" - ); - } - - #[tokio::test] - async fn pairing_sets_secure_cookie_for_remote_https_origin() { - let config = ServerConfig { - listen: "0.0.0.0:17373".parse().unwrap(), - public_origin: Some("https://idea.example.com".to_owned()), - allow_remote: true, - trust_reverse_proxy: true, - ..test_config() - }; - let state = Arc::new(ServerState::new_for_test(config, "PAIR1234")); - - let mut req = Request::builder() - .method(Method::POST) - .uri("/api/pair") - .header(ORIGIN, "https://idea.example.com") - .header(CONTENT_TYPE, "application/json") - .body(Full::new(Bytes::from_static(br#"{"code":"PAIR1234"}"#))) - .unwrap(); - *req.headers_mut().get_mut(CONTENT_TYPE).unwrap() = - HeaderValue::from_static("application/json"); - - let response = handle_request(req, state).await; - let (status, _, headers) = response_json(response).await; - - assert_eq!(status, StatusCode::OK); - let cookie = headers.get(SET_COOKIE).unwrap().to_str().unwrap(); - assert!(cookie.contains("Secure")); - assert!(cookie.contains("HttpOnly")); - assert!(cookie.contains("SameSite=Strict")); - } - - #[tokio::test] - async fn token_or_secret_in_query_string_is_refused() { - let state = state(); - - let response = request( - state, - Method::POST, - "/api/invoke?token=abc", - json!({ "command": "health", "args": {} }), - &[], - ) - .await; - let (status, body, _) = response_json(response).await; - - assert_eq!(status, StatusCode::BAD_REQUEST); - assert_eq!(body["code"], "INVALID"); - } - - #[tokio::test] - async fn serves_web_index_same_origin_with_csp() { - let state = state(); - - let response = request(state, Method::GET, "/", json!({}), &[]).await; - let (status, body, headers) = response_bytes(response).await; - - assert_eq!(status, StatusCode::OK); - assert!(std::str::from_utf8(&body).unwrap().contains("id=\"root\"")); - assert_eq!( - headers.get(CONTENT_TYPE).unwrap(), - "text/html; charset=utf-8" - ); - assert!(headers.contains_key("content-security-policy")); - assert_eq!(headers.get("x-content-type-options").unwrap(), "nosniff"); - } - - #[tokio::test] - async fn serves_static_assets_with_mime_types() { - let state = state(); - - let response = request(state, Method::GET, "/assets/app.js", json!({}), &[]).await; - let (status, body, headers) = response_bytes(response).await; - - assert_eq!(status, StatusCode::OK); - assert_eq!(std::str::from_utf8(&body).unwrap(), "console.log('idea');"); - assert_eq!( - headers.get(CONTENT_TYPE).unwrap(), - "text/javascript; charset=utf-8" - ); - } - - #[tokio::test] - async fn serves_spa_fallback_for_non_api_routes_without_extension() { - let state = state(); - - let response = request(state, Method::GET, "/projects/abc", json!({}), &[]).await; - let (status, body, headers) = response_bytes(response).await; - - assert_eq!(status, StatusCode::OK); - assert!(std::str::from_utf8(&body).unwrap().contains("id=\"root\"")); - assert!(headers.contains_key("content-security-policy")); - } - - #[tokio::test] - async fn missing_asset_and_api_routes_do_not_fallback_to_spa() { - let state = state(); - let cookie = pair_and_cookie(Arc::clone(&state)).await; - - let missing_asset = request( - Arc::clone(&state), - Method::GET, - "/assets/missing.js", - json!({}), - &[], - ) - .await; - let (asset_status, asset_body, _) = response_json(missing_asset).await; - assert_eq!(asset_status, StatusCode::NOT_FOUND); - assert_eq!(asset_body["code"], "NOT_FOUND"); - - let api_unknown = request( - state, - Method::GET, - "/api/unknown", - json!({}), - &[("cookie", &cookie)], - ) - .await; - let (api_status, api_body, _) = response_json(api_unknown).await; - assert_eq!(api_status, StatusCode::NOT_FOUND); - assert_eq!(api_body["code"], "NOT_FOUND"); - } - - #[test] - fn static_route_rejects_traversal_and_dotfiles() { - let root = PathBuf::from("/tmp/web"); - - assert!(static_route(&root, "/../secret").is_none()); - assert!(static_route(&root, "/%2e%2e/secret").is_none()); - assert!(static_route(&root, "/.env").is_none()); - assert_eq!( - static_route(&root, "/dashboard").unwrap(), - StaticRoute { - candidate: root.join("dashboard"), - spa_fallback: true - } - ); - assert_eq!( - static_route(&root, "/assets/app.js").unwrap(), - StaticRoute { - candidate: root.join("assets").join("app.js"), - spa_fallback: false - } - ); - } - - #[test] - fn explicit_web_root_requires_index_html() { - let missing = std::env::temp_dir().join(format!("idea-empty-web-{}", Uuid::new_v4())); - std::fs::create_dir_all(&missing).unwrap(); - - assert!(validate_web_root(missing, "--web-root").is_err()); - assert!(validate_web_root(create_web_root(), "--web-root").is_ok()); - } - - #[tokio::test] - async fn non_allowed_origin_is_refused() { - let state = state(); - let mut req = Request::builder() - .method(Method::POST) - .uri("/api/pair") - .header(ORIGIN, "https://evil.example") - .header(CONTENT_TYPE, "application/json") - .body(Full::new(Bytes::from_static(br#"{"code":"PAIR1234"}"#))) - .unwrap(); - *req.headers_mut().get_mut(CONTENT_TYPE).unwrap() = - HeaderValue::from_static("application/json"); - - let response = handle_request(req, state).await; - let (status, body, _) = response_json(response).await; - - assert_eq!(status, StatusCode::FORBIDDEN); - assert_eq!(body["code"], "FORBIDDEN"); - } - - #[tokio::test] - async fn unknown_allowlisted_command_returns_unknown_command() { - let state = state(); - let cookie = pair_and_cookie(Arc::clone(&state)).await; - - let response = request( - state, - Method::POST, - "/api/invoke", - json!({ "command": "debug_dump", "args": {} }), - &[("cookie", &cookie)], - ) - .await; - let (status, body, _) = response_json(response).await; - - assert_eq!(status, StatusCode::BAD_REQUEST); - assert_eq!(body["code"], "UNKNOWN_COMMAND"); - } - - #[tokio::test] - async fn authorized_health_invoke_returns_health_report() { - let state = state(); - let cookie = pair_and_cookie(Arc::clone(&state)).await; - - let response = request( - state, - Method::POST, - "/api/invoke", - json!({ "command": "health", "args": { "request": { "note": "hi" } } }), - &[("cookie", &cookie)], - ) - .await; - let (status, body, _) = response_json(response).await; - - assert_eq!(status, StatusCode::OK); - assert_eq!(body["alive"], true); - assert_eq!(body["note"], "hi"); - } - - #[tokio::test] - async fn authorized_list_projects_returns_tauri_project_list_contract() { - let state = state(); - let project_id = create_project_for_test(&state, "Web Read").await; - let cookie = pair_and_cookie(Arc::clone(&state)).await; - - let response = request( - state, - Method::POST, - "/api/invoke", - json!({ "command": "list_projects", "args": {} }), - &[("cookie", &cookie)], - ) - .await; - let (status, body, _) = response_json(response).await; - - assert_eq!(status, StatusCode::OK); - let projects = body - .as_array() - .expect("ProjectListDto is a transparent array"); - let project = projects - .iter() - .find(|project| project["id"] == project_id) - .expect("created project is listed"); - assert_eq!(project["name"], "Web Read"); - assert!(project["root"] - .as_str() - .is_some_and(|root| root.contains("idea-server-project-"))); - } - - #[tokio::test] - async fn authorized_open_project_returns_readonly_project_dto() { - let state = state(); - let project_id = create_project_for_test(&state, "Readonly Open").await; - let cookie = pair_and_cookie(Arc::clone(&state)).await; - - let response = request( - state, - Method::POST, - "/api/invoke", - json!({ "command": "open_project", "args": { "projectId": project_id } }), - &[("cookie", &cookie)], - ) - .await; - let (status, body, _) = response_json(response).await; - - assert_eq!(status, StatusCode::OK); - assert_eq!(body["id"], project_id); - assert_eq!(body["name"], "Readonly Open"); - assert!(body["root"].is_string()); - } - - #[tokio::test] - async fn authorized_get_project_work_state_returns_tauri_contract() { - let state = state(); - let project_id = create_project_for_test(&state, "Readonly Work").await; - let cookie = pair_and_cookie(Arc::clone(&state)).await; - - let response = request( - state, - Method::POST, - "/api/invoke", - json!({ "command": "get_project_work_state", "args": { "projectId": project_id } }), - &[("cookie", &cookie)], - ) - .await; - let (status, body, _) = response_json(response).await; - - assert_eq!(status, StatusCode::OK); - assert!(body["agents"].as_array().is_some()); - assert!(body["conversations"].as_array().is_some()); - } - - #[tokio::test] - async fn authorized_list_background_tasks_returns_tauri_contract() { - let state = state(); - let project_id = create_project_for_test(&state, "Background Snapshot").await; - let (task_id, owner) = - create_background_task_for_test(&state, &project_id, "web background").await; - let cookie = pair_and_cookie(Arc::clone(&state)).await; - - let response = request( - state, - Method::POST, - "/api/invoke", - json!({ - "command": "list_background_tasks", - "args": { "projectId": project_id, "agentId": owner.to_string() } - }), - &[("cookie", &cookie)], - ) - .await; - let (status, body, _) = response_json(response).await; - - assert_eq!(status, StatusCode::OK); - let tasks = body.as_array().expect("background task list is an array"); - assert_eq!(tasks.len(), 1); - assert_eq!(tasks[0]["taskId"], task_id.to_string()); - assert_eq!(tasks[0]["ownerAgentId"], owner.to_string()); - assert_eq!(tasks[0]["projectId"], project_id); - assert_eq!(tasks[0]["kind"], "command"); - assert_eq!(tasks[0]["state"], "queued"); - } - - #[tokio::test] - async fn background_actions_are_allowlisted_with_auth_gate() { - let state = state(); - let cookie = pair_and_cookie(Arc::clone(&state)).await; - let bad_task_id = "not-a-task-id"; - - for command in ["cancel_background_task", "retry_background_task"] { - let response = request( - Arc::clone(&state), - Method::POST, - "/api/invoke", - json!({ "command": command, "args": { "taskId": bad_task_id } }), - &[("cookie", &cookie)], - ) - .await; - let (status, body, _) = response_json(response).await; - - assert_eq!(status, StatusCode::BAD_REQUEST); - assert_eq!( - body["code"], "INVALID", - "{command} must be an explicit action, not UNKNOWN_COMMAND" - ); - } - } - - #[tokio::test] - async fn mutation_and_pty_commands_stay_out_of_readonly_allowlist() { - let state = state(); - let cookie = pair_and_cookie(Arc::clone(&state)).await; - - for command in ["create_project", "open_terminal", "launch_agent"] { - let response = request( - Arc::clone(&state), - Method::POST, - "/api/invoke", - json!({ "command": command, "args": {} }), - &[("cookie", &cookie)], - ) - .await; - let (status, body, _) = response_json(response).await; - - assert_eq!(status, StatusCode::BAD_REQUEST); - assert_eq!( - body["code"], "UNKNOWN_COMMAND", - "{command} must stay blocked" - ); - } - } - - #[tokio::test] - async fn invalid_project_id_is_mapped_to_error_dto() { - let state = state(); - let cookie = pair_and_cookie(Arc::clone(&state)).await; - - let response = request( - state, - Method::POST, - "/api/invoke", - json!({ "command": "open_project", "args": { "projectId": "nope" } }), - &[("cookie", &cookie)], - ) - .await; - let (status, body, _) = response_json(response).await; - - assert_eq!(status, StatusCode::BAD_REQUEST); - assert_eq!(body["code"], "INVALID"); - } - - #[test] - fn direct_project_id_parser_still_rejects_invalid_ids() { - assert!(parse_project_id("not-a-uuid").is_err()); - } + web_server::run_from_args(args) } diff --git a/crates/app-tauri/src/tickets.rs b/crates/app-tauri/src/tickets.rs index 37f9452..bab1524 100644 --- a/crates/app-tauri/src/tickets.rs +++ b/crates/app-tauri/src/tickets.rs @@ -1743,15 +1743,6 @@ fn required_str<'a>(arguments: &'a Value, key: &str) -> Result<&'a str, TicketTo .ok_or_else(|| TicketToolError::new("invalid", format!("missing {key}"))) } -impl ErrorDto { - fn invalid(message: impl Into) -> Self { - Self { - code: "INVALID".to_owned(), - message: message.into(), - } - } -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/backend/src/dto.rs b/crates/backend/src/dto.rs new file mode 100644 index 0000000..87a09bf --- /dev/null +++ b/crates/backend/src/dto.rs @@ -0,0 +1,3495 @@ +//! Data Transfer Objects crossing the IPC boundary. +//! +//! Convention (frozen here for L1, see L1-ipc-bridge.md "points d'attention"): +//! **all IPC payloads are `camelCase`** via `#[serde(rename_all = "camelCase")]`. +//! Rust uses `snake_case` fields; serde renames them on the wire so the +//! TypeScript side sees idiomatic camelCase. This matches the persisted-domain +//! JSON convention already used in the domain (`agents.json` etc.). + +use serde::{Deserialize, Serialize}; + +use application::{ + AgentBackgroundTaskState, AgentTicketState, AppError, AttachLiveAgentOutput, + BackgroundTaskKindLabel, ConversationPreviewStatus, ConversationTurnWorkPreview, + ConversationWorkSummary, CreateProjectInput, CreateProjectOutput, GitGraphOutput, HealthInput, + HealthReport, LayoutKind, ListProjectsOutput, LiveSessionKind, LiveSessionSnapshot, + OpenProjectOutput, ProjectWorkState, StopLiveAgentOutput, TicketWorkSource, TicketWorkStatus, + TurnPage, TurnSource, TurnView, +}; +use domain::{AgentBusyState, PageCursor, PageDirection, Project, ProjectId, TurnRole}; + +/// Request DTO for the `health` command. +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct HealthRequestDto { + /// Optional note echoed back by the use case. + #[serde(default)] + pub note: Option, +} + +impl From for HealthInput { + fn from(dto: HealthRequestDto) -> Self { + Self { note: dto.note } + } +} + +/// Response DTO for the `health` command. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct HealthResponseDto { + /// Application version. + pub version: String, + /// Liveness flag. + pub alive: bool, + /// Server time in epoch milliseconds. + pub time_millis: i64, + /// Correlation id for this call. + pub correlation_id: String, + /// Echoed note, if any. + pub note: Option, +} + +impl From for HealthResponseDto { + fn from(r: HealthReport) -> Self { + Self { + version: r.version, + alive: r.alive, + time_millis: r.time_millis, + correlation_id: r.correlation_id, + note: r.note, + } + } +} + +/// Error DTO returned to the frontend in the `Err` arm of every command. +/// +/// `code` is a stable machine-readable string (see [`AppError::code`]); the +/// frontend branches on it without parsing `message`. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ErrorDto { + /// Stable error code, e.g. `NOT_FOUND`, `INVALID`. + pub code: String, + /// Human-readable message. + pub message: String, +} + +impl From for ErrorDto { + fn from(e: AppError) -> Self { + Self { + code: e.code().to_owned(), + message: e.to_string(), + } + } +} + +impl ErrorDto { + /// Builds a stable `INVALID` error. + #[must_use] + pub fn invalid(message: impl Into) -> Self { + Self { + code: "INVALID".to_owned(), + message: message.into(), + } + } +} + +// --------------------------------------------------------------------------- +// Projects (L2) +// --------------------------------------------------------------------------- + +/// A project as seen by the frontend (camelCase wire shape). +/// +/// `remote` is the domain [`domain::RemoteRef`], which already serialises +/// camelCase + tagged (`kind`), so we embed it directly. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ProjectDto { + /// Stable project id (UUID string). + pub id: String, + /// Display name. + pub name: String, + /// Absolute project root. + pub root: String, + /// Where the project lives (`{ "kind": "local" }`, `ssh`, `wsl`). + pub remote: domain::RemoteRef, + /// Creation timestamp, epoch milliseconds. + pub created_at: i64, +} + +impl From for ProjectDto { + fn from(p: Project) -> Self { + Self { + id: p.id.to_string(), + name: p.name, + root: p.root.as_str().to_owned(), + remote: p.remote, + created_at: p.created_at, + } + } +} + +/// Request DTO for `create_project`. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateProjectRequestDto { + /// Display name. + pub name: String, + /// Absolute project root. + pub root: String, + /// Optional remote reference; defaults to local when omitted. + #[serde(default)] + pub remote: Option, + /// Optional default profile id. + #[serde(default)] + pub default_profile_id: Option, +} + +impl From for CreateProjectInput { + fn from(dto: CreateProjectRequestDto) -> Self { + Self { + name: dto.name, + root: dto.root, + remote: dto.remote, + default_profile_id: dto.default_profile_id, + } + } +} + +impl From for ProjectDto { + fn from(out: CreateProjectOutput) -> Self { + out.project.into() + } +} + +impl From for ProjectDto { + fn from(out: OpenProjectOutput) -> Self { + out.project.into() + } +} + +/// Parses a project-id string (UUID) coming from the frontend. +/// +/// # Errors +/// Returns an [`ErrorDto`] with code `INVALID` if the string is not a UUID. +pub fn parse_project_id(raw: &str) -> Result { + uuid::Uuid::parse_str(raw) + .map(ProjectId::from_uuid) + .map_err(|_| ErrorDto { + code: "INVALID".to_owned(), + message: format!("invalid project id: {raw}"), + }) +} + +/// Response DTO for `list_projects`. +#[derive(Debug, Clone, Serialize)] +#[serde(transparent)] +pub struct ProjectListDto(pub Vec); + +impl From for ProjectListDto { + fn from(out: ListProjectsOutput) -> Self { + Self(out.projects.into_iter().map(ProjectDto::from).collect()) + } +} + +// --------------------------------------------------------------------------- +// Terminals (L3) +// --------------------------------------------------------------------------- + +use application::{ + CloseTerminalInput, CloseTerminalOutput, OpenTerminalInput, OpenTerminalOutput, + ResizeTerminalInput, WriteToTerminalInput, +}; +use domain::SessionId; + +/// Request DTO for `open_terminal`. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OpenTerminalRequestDto { + /// Working directory (typically the project root). + pub cwd: String, + /// Initial terminal height in rows. + pub rows: u16, + /// Initial terminal width in columns. + pub cols: u16, + /// Optional explicit command; defaults to the platform shell when omitted. + #[serde(default)] + pub command: Option, + /// Optional arguments for the command. + #[serde(default)] + pub args: Vec, +} + +impl From for OpenTerminalInput { + fn from(dto: OpenTerminalRequestDto) -> Self { + Self { + cwd: dto.cwd, + rows: dto.rows, + cols: dto.cols, + command: dto.command, + args: dto.args, + node_id: None, + } + } +} + +/// Response DTO for `open_terminal`: the freshly-opened session. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TerminalSessionDto { + /// Stable session id (UUID string) — used for write/resize/close + the + /// output channel. + pub session_id: String, + /// Working directory the shell runs in. + pub cwd: String, + /// Current rows. + pub rows: u16, + /// Current cols. + pub cols: u16, + /// Conversation id **assigned** by this launch, when the agent's profile + /// supports session assignment and the hosting cell had none yet (T4b). The + /// front persists it on the leaf (`setCellConversation`) so the next open + /// resumes. `None` for a plain terminal, a resume, or a degraded launch. + #[serde(skip_serializing_if = "Option::is_none")] + pub assigned_conversation_id: Option, + /// **Id de session moteur** (resumable du provider courant) exposé par ce + /// lancement, **distinct** de l'id de paire `assignedConversationId` (ARCHITECTURE + /// §19.7, lot P8a). Le front le range dans le **cache** `engineSessionId` de la + /// cellule (jamais sur `conversationId`). Absent pour un terminal nu, une reprise, + /// ou quand le moteur n'expose encore aucun id. La source de vérité du resumable + /// est `providers.json` (lot P8b). + #[serde(skip_serializing_if = "Option::is_none")] + pub engine_session_id: Option, + /// How the frontend should render the cell hosting this session (§17.6): + /// [`CellKind::Chat`] for a structured AI session (an `AgentChatView` driven by + /// `agent_send`/`reattach_agent_chat`), [`CellKind::Pty`] for a raw terminal + /// (xterm). **Derived**, not a layout field: it follows the presence of a + /// structured session descriptor on the launch output — a single source of + /// truth. Plain terminals and the non-launch construction paths default to + /// [`CellKind::Pty`] (the historical, non-breaking shape: a PTY session DTO + /// always serialises with `cellKind: "pty"`). + pub cell_kind: CellKind, +} + +/// Whether a session's hosting cell renders as a structured chat view or a raw +/// terminal (ARCHITECTURE §17.6). Serialises as `"chat"` / `"pty"` on the wire; +/// the frontend `LayoutGrid` switches `AgentChatView` vs `TerminalView` on it. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum CellKind { + /// A raw PTY terminal cell (xterm) — the historical default. + Pty, + /// A structured AI chat cell (`AgentChatView`), backed by an `AgentSession`. + Chat, +} + +impl From for TerminalSessionDto { + fn from(out: OpenTerminalOutput) -> Self { + let s = out.session; + Self { + session_id: s.id.to_string(), + cwd: s.cwd.as_str().to_owned(), + rows: s.pty_size.rows, + cols: s.pty_size.cols, + assigned_conversation_id: None, + engine_session_id: None, + cell_kind: CellKind::Pty, + } + } +} + +/// Response DTO for `reattach_terminal`: the retained scrollback of a still-live +/// session, repainted into the re-mounting xterm before the new output stream is +/// wired. Bytes are serialised as a number array, matching the PTY output channel. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ReattachResultDto { + /// The session that was re-attached (echoed back for the frontend). + pub session_id: String, + /// The most-recent retained output bytes (scrollback ring buffer). + pub scrollback: Vec, +} + +/// Request DTO for `write_terminal`. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WriteTerminalRequestDto { + /// Target session id. + pub session_id: String, + /// Bytes to write (xterm keystrokes). + pub data: Vec, +} + +impl WriteTerminalRequestDto { + /// Converts to the use-case input, parsing the session id. + /// + /// # Errors + /// [`ErrorDto`] with code `INVALID` if the id is malformed. + pub fn into_input(self) -> Result { + Ok(WriteToTerminalInput { + session_id: parse_session_id(&self.session_id)?, + data: self.data, + }) + } +} + +/// Request DTO for `resize_terminal`. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ResizeTerminalRequestDto { + /// Target session id. + pub session_id: String, + /// New rows. + pub rows: u16, + /// New cols. + pub cols: u16, +} + +impl ResizeTerminalRequestDto { + /// Converts to the use-case input, parsing the session id. + /// + /// # Errors + /// [`ErrorDto`] with code `INVALID` if the id is malformed. + pub fn into_input(self) -> Result { + Ok(ResizeTerminalInput { + session_id: parse_session_id(&self.session_id)?, + rows: self.rows, + cols: self.cols, + }) + } +} + +/// Response DTO for `close_terminal`. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TerminalClosedDto { + /// Exit code, if the process reported one. + pub code: Option, +} + +impl From for TerminalClosedDto { + fn from(out: CloseTerminalOutput) -> Self { + Self { code: out.code } + } +} + +/// Builds a [`CloseTerminalInput`] from a raw session-id string. +/// +/// # Errors +/// [`ErrorDto`] with code `INVALID` if the id is malformed. +pub fn parse_close_terminal(raw: &str) -> Result { + Ok(CloseTerminalInput { + session_id: parse_session_id(raw)?, + }) +} + +/// Parses a session-id string (UUID) coming from the frontend. +/// +/// # Errors +/// Returns an [`ErrorDto`] with code `INVALID` if the string is not a UUID. +pub fn parse_session_id(raw: &str) -> Result { + uuid::Uuid::parse_str(raw) + .map(SessionId::from_uuid) + .map_err(|_| ErrorDto { + code: "INVALID".to_owned(), + message: format!("invalid session id: {raw}"), + }) +} + +// --------------------------------------------------------------------------- +// Layout (L4 + #4 management + #3 per-cell agent) +// --------------------------------------------------------------------------- + +use application::{ + CreateLayoutOutput, DeleteLayoutOutput, LayoutInfo, LayoutOperation, ListLayoutsOutput, + LoadLayoutOutput, MutateLayoutOutput, SetActiveLayoutOutput, +}; +use domain::{AgentId, Direction, LayoutId, LayoutTree, NodeId}; + +/// Response DTO carrying a layout tree. +/// +/// [`LayoutTree`] already serialises camelCase + tagged (its enum uses +/// `#[serde(tag = "type", content = "node")]`), so we embed it directly; the +/// TypeScript mirror in `@/domain` matches this shape. +#[derive(Debug, Clone, Serialize)] +#[serde(transparent)] +pub struct LayoutDto(pub LayoutTree); + +impl From for LayoutDto { + fn from(out: LoadLayoutOutput) -> Self { + Self(out.layout) + } +} + +impl From for LayoutDto { + fn from(out: MutateLayoutOutput) -> Self { + Self(out.layout) + } +} + +/// A layout operation as sent by the frontend (tagged on `type`, camelCase). +/// +/// Mirrors [`LayoutOperation`]; node/session ids cross the wire as UUID strings +/// and are parsed here. `direction` reuses the domain [`Direction`] (which +/// already serialises `"row"`/`"column"`). +#[derive(Debug, Clone, Deserialize)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum LayoutOperationDto { + /// Split a leaf into a two-child split. + #[serde(rename_all = "camelCase")] + Split { + /// Leaf to split. + target: String, + /// Row (columns) or Column (rows). + direction: Direction, + /// Id for the new sibling leaf. + new_leaf: String, + /// Id for the wrapping split container. + container: String, + }, + /// Collapse a split container back to one child. + #[serde(rename_all = "camelCase")] + Merge { + /// Split container to collapse. + container: String, + /// Index of the child to keep. + keep_index: usize, + }, + /// Reassign a split's child weights. + #[serde(rename_all = "camelCase")] + Resize { + /// Split container to resize. + container: String, + /// New weights (one per child). + weights: Vec, + }, + /// Move a session from one leaf to another. + #[serde(rename_all = "camelCase")] + Move { + /// Source leaf. + from: String, + /// Target (empty) leaf. + to: String, + }, + /// Attach/detach a session to/from a leaf. + #[serde(rename_all = "camelCase")] + SetSession { + /// Hosting leaf. + target: String, + /// Session id, or `null` to clear. + #[serde(default)] + session: Option, + }, + /// Attach/detach an agent to/from a leaf (#3 per-cell agent). + #[serde(rename_all = "camelCase")] + SetCellAgent { + /// Hosting leaf. + target: String, + /// Agent id, or `null` to clear. + #[serde(default)] + agent: Option, + }, + /// Record/clear the persistent CLI conversation id on a leaf (T4b). + #[serde(rename_all = "camelCase")] + SetCellConversation { + /// Hosting leaf. + target: String, + /// Conversation id, or `null` to clear. + #[serde(default)] + conversation_id: Option, + }, +} + +impl LayoutOperationDto { + /// Converts to the use-case operation, parsing all ids. + /// + /// # Errors + /// [`ErrorDto`] with code `INVALID` if any id is malformed. + pub fn into_operation(self) -> Result { + Ok(match self { + Self::Split { + target, + direction, + new_leaf, + container, + } => LayoutOperation::Split { + target: parse_node_id(&target)?, + direction, + new_leaf: parse_node_id(&new_leaf)?, + container: parse_node_id(&container)?, + }, + Self::Merge { + container, + keep_index, + } => LayoutOperation::Merge { + container: parse_node_id(&container)?, + keep_index, + }, + Self::Resize { container, weights } => LayoutOperation::Resize { + container: parse_node_id(&container)?, + weights, + }, + Self::Move { from, to } => LayoutOperation::Move { + from: parse_node_id(&from)?, + to: parse_node_id(&to)?, + }, + Self::SetSession { target, session } => LayoutOperation::SetSession { + target: parse_node_id(&target)?, + session: session.as_deref().map(parse_session_id).transpose()?, + }, + Self::SetCellAgent { target, agent } => LayoutOperation::SetCellAgent { + target: parse_node_id(&target)?, + agent: agent.as_deref().map(parse_agent_id).transpose()?, + }, + Self::SetCellConversation { + target, + conversation_id, + } => LayoutOperation::SetCellConversation { + target: parse_node_id(&target)?, + conversation_id, + }, + }) + } +} + +/// Parses a node-id string (UUID) coming from the frontend. +/// +/// # Errors +/// Returns an [`ErrorDto`] with code `INVALID` if the string is not a UUID. +pub fn parse_node_id(raw: &str) -> Result { + uuid::Uuid::parse_str(raw) + .map(NodeId::from_uuid) + .map_err(|_| ErrorDto { + code: "INVALID".to_owned(), + message: format!("invalid node id: {raw}"), + }) +} + +/// Parses a layout-id string (UUID) coming from the frontend. +/// +/// # Errors +/// Returns an [`ErrorDto`] with code `INVALID` if the string is not a UUID. +pub fn parse_layout_id(raw: &str) -> Result { + uuid::Uuid::parse_str(raw) + .map(LayoutId::from_uuid) + .map_err(|_| ErrorDto { + code: "INVALID".to_owned(), + message: format!("invalid layout id: {raw}"), + }) +} + +// --------------------------------------------------------------------------- +// Layouts (#4) — management DTOs +// --------------------------------------------------------------------------- + +/// Lightweight layout descriptor (id + name + kind), for the tab bar. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LayoutInfoDto { + /// Stable layout id (UUID string). + pub id: String, + /// Display name. + pub name: String, + /// Layout kind: `"terminal"` or `"gitGraph"`. + pub kind: String, +} + +impl From for LayoutInfoDto { + fn from(info: LayoutInfo) -> Self { + let kind = match info.kind { + LayoutKind::Terminal => "terminal", + LayoutKind::GitGraph => "gitGraph", + } + .to_owned(); + Self { + id: info.id.to_string(), + name: info.name, + kind, + } + } +} + +/// Response DTO for `list_layouts`. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ListLayoutsDto { + /// All named layouts (id + name), in order. + pub layouts: Vec, + /// The id of the currently active layout. + pub active_id: String, +} + +impl From for ListLayoutsDto { + fn from(out: ListLayoutsOutput) -> Self { + Self { + layouts: out.layouts.into_iter().map(LayoutInfoDto::from).collect(), + active_id: out.active_id.to_string(), + } + } +} + +/// Response DTO for `create_layout`. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateLayoutResultDto { + /// The id minted for the new layout. + pub layout_id: String, +} + +impl From for CreateLayoutResultDto { + fn from(out: CreateLayoutOutput) -> Self { + Self { + layout_id: out.layout_id.to_string(), + } + } +} + +/// Response DTO for `delete_layout`. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DeleteLayoutResultDto { + /// The active layout after the deletion. + pub active_id: String, +} + +impl From for DeleteLayoutResultDto { + fn from(out: DeleteLayoutOutput) -> Self { + Self { + active_id: out.active_id.to_string(), + } + } +} + +/// Request DTO for `create_layout`. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateLayoutRequestDto { + /// Owning project id. + pub project_id: String, + /// Display name for the new layout. + pub name: String, + /// Optional layout kind: `"terminal"` (default) or `"gitGraph"`. + #[serde(default)] + pub kind: Option, +} + +impl CreateLayoutRequestDto { + /// Parses the optional `kind` string into a [`LayoutKind`]. + /// + /// # Errors + /// [`ErrorDto`] with code `INVALID` if the value is not a known kind. + pub fn parse_kind(&self) -> Result { + match self.kind.as_deref() { + None | Some("terminal") => Ok(LayoutKind::Terminal), + Some("gitGraph") => Ok(LayoutKind::GitGraph), + Some(other) => Err(ErrorDto { + code: "INVALID".to_owned(), + message: format!("unknown layout kind: {other}"), + }), + } + } +} + +/// Request DTO for `rename_layout`. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RenameLayoutRequestDto { + /// Owning project id. + pub project_id: String, + /// Layout to rename. + pub layout_id: String, + /// New display name. + pub name: String, +} + +/// Request DTO for `delete_layout`. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DeleteLayoutRequestDto { + /// Owning project id. + pub project_id: String, + /// Layout to delete. + pub layout_id: String, +} + +/// Response DTO for `set_active_layout`. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SetActiveLayoutResultDto { + /// The layout actually activated — equals the requested id when valid, else + /// the unchanged current active id (self-healing fallback). Authoritative. + pub active_id: String, +} + +impl From for SetActiveLayoutResultDto { + fn from(out: SetActiveLayoutOutput) -> Self { + Self { + active_id: out.active_id.to_string(), + } + } +} + +/// Request DTO for `set_active_layout`. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SetActiveLayoutRequestDto { + /// Owning project id. + pub project_id: String, + /// Layout to make active. + pub layout_id: String, +} + +// --------------------------------------------------------------------------- +// Profiles & first-run (L5) +// --------------------------------------------------------------------------- + +use application::{ + CloneOpenCodeProfileFromSeedInput, CloneOpenCodeProfileFromSeedOutput, ConfigureProfilesInput, + ConfigureProfilesOutput, DeleteProfileInput, DetectProfilesInput, DetectProfilesOutput, + FirstRunStateOutput, ListProfilesOutput, ProfileAvailability, ReferenceProfilesOutput, + SaveProfileInput, SaveProfileOutput, +}; +use domain::profile::{AgentProfile, OpenCodeConfig}; +use domain::ProfileId; + +/// A profile crossing the wire. [`AgentProfile`] already serialises camelCase +/// (id, name, command, args, `contextInjection{strategy,…}`, detect, +/// `cwdTemplate`), so we embed it directly — the TS mirror matches this shape. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(transparent)] +pub struct ProfileDto(pub AgentProfile); + +/// A list of profiles (camelCase array on the wire). +#[derive(Debug, Clone, Serialize)] +#[serde(transparent)] +pub struct ProfileListDto(pub Vec); + +impl From> for ProfileListDto { + fn from(v: Vec) -> Self { + Self(v.into_iter().map(ProfileDto).collect()) + } +} + +impl From for ProfileListDto { + fn from(out: ListProfilesOutput) -> Self { + out.profiles.into() + } +} + +impl From for ProfileListDto { + fn from(out: ReferenceProfilesOutput) -> Self { + out.profiles.into() + } +} + +impl From for ProfileDto { + fn from(out: SaveProfileOutput) -> Self { + Self(out.profile) + } +} + +impl From for ProfileDto { + fn from(out: CloneOpenCodeProfileFromSeedOutput) -> Self { + Self(out.profile) + } +} + +/// Request DTO for `detect_profiles`: the candidate profiles to probe. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DetectProfilesRequestDto { + /// Candidate profiles whose `detect` command should be run. + pub candidates: Vec, +} + +impl From for DetectProfilesInput { + fn from(dto: DetectProfilesRequestDto) -> Self { + Self { + candidates: dto.candidates, + } + } +} + +/// One availability result (`profile` + whether its CLI is installed). +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ProfileAvailabilityDto { + /// The probed profile. + pub profile: AgentProfile, + /// Whether the CLI was detected (exit code 0). + pub available: bool, +} + +impl From for ProfileAvailabilityDto { + fn from(a: ProfileAvailability) -> Self { + Self { + profile: a.profile, + available: a.available, + } + } +} + +/// Response DTO for `detect_profiles`. +#[derive(Debug, Clone, Serialize)] +#[serde(transparent)] +pub struct DetectProfilesResponseDto(pub Vec); + +impl From for DetectProfilesResponseDto { + fn from(out: DetectProfilesOutput) -> Self { + Self(out.results.into_iter().map(Into::into).collect()) + } +} + +/// Request DTO for `save_profile`. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SaveProfileRequestDto { + /// The profile to upsert. + pub profile: AgentProfile, +} + +impl From for SaveProfileInput { + fn from(dto: SaveProfileRequestDto) -> Self { + Self { + profile: dto.profile, + } + } +} + +/// Request DTO for `clone_opencode_profile_from_seed`. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CloneOpenCodeProfileFromSeedRequestDto { + /// Optional display name for the new profile. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + /// Optional OpenCode config override. When omitted, the seed config is copied. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub opencode: Option, +} + +impl From for CloneOpenCodeProfileFromSeedInput { + fn from(dto: CloneOpenCodeProfileFromSeedRequestDto) -> Self { + Self { + name: dto.name, + opencode: dto.opencode, + } + } +} + +/// Request DTO for `configure_profiles` (closes the first run). +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ConfigureProfilesRequestDto { + /// All profiles the user chose to keep. + pub profiles: Vec, +} + +impl From for ConfigureProfilesInput { + fn from(dto: ConfigureProfilesRequestDto) -> Self { + Self { + profiles: dto.profiles, + } + } +} + +impl From for ProfileListDto { + fn from(out: ConfigureProfilesOutput) -> Self { + out.profiles.into() + } +} + +/// Response DTO for `first_run_state`. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct FirstRunStateDto { + /// `true` when the first-run wizard should be shown. + pub is_first_run: bool, + /// Pre-filled reference catalogue to seed the wizard. + pub reference_profiles: Vec, +} + +impl From for FirstRunStateDto { + fn from(out: FirstRunStateOutput) -> Self { + Self { + is_first_run: out.is_first_run, + reference_profiles: out.reference_profiles, + } + } +} + +// --------------------------------------------------------------------------- +// Local model servers (B35) +// --------------------------------------------------------------------------- + +use application::{ListModelServersOutput, SaveModelServerInput, SaveModelServerOutput}; +use domain::model_server::{ + ExecutablePath, HfModelRef, LlamaCppOptions, LocalModelRef, LocalModelServerConfig, + LocalModelServerKind, ModelPath, ModelServerEndpoint, ModelSource, +}; +use domain::{LocalModelServerId, StopPolicy}; + +/// Local model-server implementation on the IPC wire. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ModelServerKindDto { + /// llama.cpp `llama-server`. + LlamaCpp, +} + +impl From for ModelServerKindDto { + fn from(kind: LocalModelServerKind) -> Self { + match kind { + LocalModelServerKind::LlamaCpp => Self::LlamaCpp, + } + } +} + +impl From for LocalModelServerKind { + fn from(kind: ModelServerKindDto) -> Self { + match kind { + ModelServerKindDto::LlamaCpp => Self::LlamaCpp, + } + } +} + +/// Stop policy on the IPC wire. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum StopPolicyDto { + /// Keep the process alive. + KeepAlive, + /// Stop when no longer used. + StopWhenUnused, + /// Stop when the application exits. + StopOnAppExit, +} + +impl From for StopPolicyDto { + fn from(policy: StopPolicy) -> Self { + match policy { + StopPolicy::KeepAlive => Self::KeepAlive, + StopPolicy::StopWhenUnused => Self::StopWhenUnused, + StopPolicy::StopOnAppExit => Self::StopOnAppExit, + } + } +} + +impl From for StopPolicy { + fn from(policy: StopPolicyDto) -> Self { + match policy { + StopPolicyDto::KeepAlive => Self::KeepAlive, + StopPolicyDto::StopWhenUnused => Self::StopWhenUnused, + StopPolicyDto::StopOnAppExit => Self::StopOnAppExit, + } + } +} + +/// Model source on the IPC wire. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", tag = "type")] +pub enum ModelSourceDto { + /// Local `.gguf` path. + LocalPath { + /// Absolute local path. + path: String, + }, + /// Hugging Face `namespace/repo[:quant]` reference. + HuggingFace { + /// Repository reference. + repo: String, + }, +} + +impl From for ModelSourceDto { + fn from(source: ModelSource) -> Self { + match source { + ModelSource::LocalPath { path } => Self::LocalPath { path: path.0 }, + ModelSource::HuggingFace { repo } => Self::HuggingFace { repo: repo.0 }, + } + } +} + +impl ModelSourceDto { + fn into_domain(self) -> Result { + match self { + Self::LocalPath { path } => Ok(ModelSource::LocalPath { + path: ModelPath::new(path).map_err(invalid_domain_error)?, + }), + Self::HuggingFace { repo } => Ok(ModelSource::HuggingFace { + repo: HfModelRef::new(repo).map_err(invalid_domain_error)?, + }), + } + } +} + +/// A local model-server config crossing the wire. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ModelServerConfigDto { + /// Stable local server config id. + pub id: String, + /// Server implementation kind. + pub kind: ModelServerKindDto, + /// Display name. + pub name: String, + /// OpenAI-compatible base URL. + #[serde(rename = "baseURL")] + pub base_url: String, + /// TCP port. + pub port: u16, + /// Optional explicit model source. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model_source: Option, + /// Legacy V1 input alias for `modelSource:{type:"localPath",path}`. + #[serde(default, skip_serializing)] + pub model_path: Option, + /// Served model name exposed to OpenCode. + pub served_model_name: String, + /// Optional explicit `llama-server` executable path. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub binary_path: Option, + /// llama.cpp host passed to `--host`. + #[serde(default = "default_llamacpp_host")] + pub host: String, + /// llama.cpp GPU layers passed to `-ngl`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub gpu_layers: Option, + /// llama.cpp context size passed to `-c`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub context_size: Option, + /// Whether to pass `--jinja`. + #[serde(default)] + pub jinja: bool, + /// Extra argv entries. + #[serde(default)] + pub args: Vec, + /// Whether IdeA should start the server lazily. + pub auto_start: bool, + /// Stop policy. + pub stop_policy: StopPolicyDto, + /// Optional readiness warmup deadline override in seconds. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub warmup_deadline_secs: Option, +} + +impl ModelServerConfigDto { + /// Maps a domain config to the flat IPC DTO. + #[must_use] + pub fn from_domain(config: LocalModelServerConfig) -> Self { + Self { + id: config.id.to_string(), + kind: config.kind.into(), + name: config.name, + base_url: config.endpoint.base_url, + port: config.endpoint.port, + model_source: config.model.source.map(Into::into), + model_path: None, + served_model_name: config.model.served_name, + binary_path: config.binary.map(|binary| binary.as_str().to_owned()), + host: config.options.host, + gpu_layers: config.options.gpu_layers, + context_size: config.options.context_size, + jinja: config.options.jinja, + args: config.args, + auto_start: config.auto_start, + stop_policy: config.stop_policy.into(), + warmup_deadline_secs: config.warmup_deadline_secs, + } + } + + /// Maps the flat IPC DTO into the domain config, preserving internal model id + /// from the existing config when available. + /// + /// # Errors + /// [`ErrorDto`] with `INVALID` code if any domain invariant rejects the input. + pub fn into_domain( + self, + existing: Option<&LocalModelServerConfig>, + ) -> Result { + let server_id = parse_model_server_id(&self.id)?; + let endpoint = ModelServerEndpoint::new(self.base_url.clone(), self.port) + .map_err(invalid_domain_error)?; + let source = resolve_model_source(self.model_source, self.model_path)?; + let model_id = existing + .filter(|config| config.id == server_id) + .map(|config| config.model.id.clone()) + .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()); + let label = non_empty_or_fallback(&self.served_model_name, &self.name); + let model = LocalModelRef::new(model_id, label, source, self.served_model_name.clone()) + .map_err(invalid_domain_error)?; + let binary = optional_non_empty(self.binary_path) + .map(ExecutablePath::new) + .transpose() + .map_err(invalid_domain_error)?; + let options = + LlamaCppOptions::new(self.host, self.gpu_layers, self.context_size, self.jinja) + .map_err(invalid_domain_error)?; + LocalModelServerConfig::new( + server_id, + self.kind.into(), + self.name, + endpoint, + model, + binary, + options, + self.args, + self.auto_start, + self.stop_policy.into(), + ) + .and_then(|config| config.with_warmup_deadline_secs(self.warmup_deadline_secs)) + .map_err(invalid_domain_error) + } +} + +/// Response DTO for `preview_model_server_command`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PreviewModelServerCommandDto { + /// Executable command. + pub command: String, + /// Arguments without shell parsing. + pub args: Vec, + /// Human-readable escaped command line. + pub display: String, +} + +/// List response for local model servers. +#[derive(Debug, Clone, Serialize)] +#[serde(transparent)] +pub struct ModelServerConfigListDto(pub Vec); + +impl From for ModelServerConfigListDto { + fn from(out: ListModelServersOutput) -> Self { + Self( + out.servers + .into_iter() + .map(ModelServerConfigDto::from_domain) + .collect(), + ) + } +} + +/// Request DTO for `save_model_server`. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SaveModelServerRequestDto { + /// Config to upsert. + pub config: ModelServerConfigDto, +} + +impl From for ModelServerConfigDto { + fn from(out: SaveModelServerOutput) -> Self { + Self::from_domain(out.config) + } +} + +/// Parses a local model-server id string. +/// +/// # Errors +/// [`ErrorDto`] with `INVALID` code if the string is not a UUID. +pub fn parse_model_server_id(raw: &str) -> Result { + uuid::Uuid::parse_str(raw) + .map(LocalModelServerId::from_uuid) + .map_err(|_| ErrorDto { + code: "INVALID".to_owned(), + message: format!("invalid model server id: {raw}"), + }) +} + +/// Builds a save-model-server input after the caller has resolved the existing +/// config, if any. +/// +/// # Errors +/// [`ErrorDto`] if DTO-to-domain validation fails. +pub fn save_model_server_input( + request: SaveModelServerRequestDto, + existing: Option<&LocalModelServerConfig>, +) -> Result { + Ok(SaveModelServerInput { + config: request.config.into_domain(existing)?, + }) +} + +/// Converts a model-server DTO to domain using the same path as save. +/// +/// # Errors +/// [`ErrorDto`] if DTO-to-domain validation fails. +pub fn model_server_config_domain( + config: ModelServerConfigDto, + existing: Option<&LocalModelServerConfig>, +) -> Result { + config.into_domain(existing) +} + +fn resolve_model_source( + model_source: Option, + model_path: Option, +) -> Result, ErrorDto> { + let legacy_path = optional_non_empty(model_path); + match (model_source, legacy_path) { + (None, None) => Ok(None), + (None, Some(path)) => Ok(Some(ModelSource::LocalPath { + path: ModelPath::new(path).map_err(invalid_domain_error)?, + })), + (Some(source), None) => Ok(Some(source.into_domain()?)), + (Some(source), Some(path)) => { + let domain_source = source.into_domain()?; + match &domain_source { + ModelSource::LocalPath { path: source_path } if source_path.as_str() == path => { + Ok(Some(domain_source)) + } + _ => Err(ErrorDto { + code: "INVALID".to_owned(), + message: "modelPath and modelSource differ".to_owned(), + }), + } + } + } +} + +fn optional_non_empty(raw: Option) -> Option { + raw.map(|value| value.trim().to_owned()) + .filter(|value| !value.is_empty()) +} + +fn default_llamacpp_host() -> String { + "127.0.0.1".to_owned() +} + +fn non_empty_or_fallback(primary: &str, fallback: &str) -> String { + let primary = primary.trim(); + if primary.is_empty() { + fallback.trim().to_owned() + } else { + primary.to_owned() + } +} + +fn invalid_domain_error(error: impl std::fmt::Display) -> ErrorDto { + ErrorDto { + code: "INVALID".to_owned(), + message: error.to_string(), + } +} + +// --------------------------------------------------------------------------- +// Embedder profiles & engines (LOT C2 — §14.5.3) +// --------------------------------------------------------------------------- + +use application::{ + DismissChoice, DismissEmbedderSuggestionInput, EmbedderEnginesView, ListEmbedderProfilesOutput, + OnnxModelView, SaveEmbedderProfileInput, SaveEmbedderProfileOutput, +}; +use domain::profile::EmbedderProfile; + +/// An embedder profile crossing the wire. [`EmbedderProfile`] already serialises +/// camelCase (`id, name, strategy, model?, endpoint?, apiKeyEnv?, dimension`), so we +/// embed it directly — the TS mirror matches this shape. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(transparent)] +pub struct EmbedderProfileDto(pub EmbedderProfile); + +/// A list of embedder profiles (camelCase array on the wire). +#[derive(Debug, Clone, Serialize)] +#[serde(transparent)] +pub struct EmbedderProfileListDto(pub Vec); + +impl From> for EmbedderProfileListDto { + fn from(v: Vec) -> Self { + Self(v.into_iter().map(EmbedderProfileDto).collect()) + } +} + +impl From for EmbedderProfileListDto { + fn from(out: ListEmbedderProfilesOutput) -> Self { + out.profiles.into() + } +} + +impl From for EmbedderProfileDto { + fn from(out: SaveEmbedderProfileOutput) -> Self { + Self(out.profile) + } +} + +/// Request DTO for `save_embedder_profile`: the embedder profile to upsert. +/// +/// Carries the [`EmbedderProfile`] fields directly (the entity validates them in the +/// use case). Deserialised camelCase to match the persisted/domain shape. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SaveEmbedderProfileRequestDto { + /// The embedder profile to upsert. + pub profile: EmbedderProfile, +} + +impl From for SaveEmbedderProfileInput { + fn from(dto: SaveEmbedderProfileRequestDto) -> Self { + let EmbedderProfile { + id, + name, + strategy, + model, + endpoint, + api_key_env, + dimension, + } = dto.profile; + Self { + id, + name, + strategy, + model, + endpoint, + api_key_env, + dimension, + } + } +} + +/// One recommendable local ONNX model on the wire (camelCase). +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct OnnxModelInfoDto { + /// Stable model id accepted by a `localOnnx` profile's `model` field. + pub id: String, + /// Human-readable name for the UI. + pub display_name: String, + /// Length of the vectors this model produces. + pub dimension: usize, + /// Approximate download/disk size in megabytes. + pub approx_size_mb: u32, + /// Whether this is the recommended default model. + pub recommended: bool, +} + +impl From for OnnxModelInfoDto { + fn from(m: OnnxModelView) -> Self { + Self { + id: m.id, + display_name: m.display_name, + dimension: m.dimension, + approx_size_mb: m.approx_size_mb, + recommended: m.recommended, + } + } +} + +/// Response DTO for `describe_embedder_engines` (drives the "configure an embedder?" +/// UI). All-camelCase wire shape. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct EmbedderEnginesDto { + /// The curated catalogue of recommendable local ONNX models. + pub recommended_onnx: Vec, + /// Whether an Ollama-style local embedding server was detected (best-effort). + pub ollama_detected: bool, + /// Ids of the recommended ONNX models already present in the local cache. + pub onnx_cached_models: Vec, + /// Whether the HTTP capability (`localServer`/`api`) is compiled into this binary. + pub vector_http_enabled: bool, + /// Whether the in-process ONNX capability (`localOnnx`) is compiled into this binary. + pub vector_onnx_enabled: bool, +} + +impl From for EmbedderEnginesDto { + fn from(v: EmbedderEnginesView) -> Self { + Self { + recommended_onnx: v.recommended_onnx.into_iter().map(Into::into).collect(), + ollama_detected: v.ollama_detected, + onnx_cached_models: v.onnx_cached_models, + vector_http_enabled: v.vector_http_enabled, + vector_onnx_enabled: v.vector_onnx_enabled, + } + } +} + +// --------------------------------------------------------------------------- +// Embedder suggestion (LOT C3 — §14.5.5) +// --------------------------------------------------------------------------- + +/// The user's response to the embedder suggestion, on the wire (camelCase: +/// `"later"` | `"never"`). +#[derive(Debug, Clone, Copy, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum DismissChoiceDto { + /// "Plus tard" — re-proposable next session. + Later, + /// "Ne plus demander" — never again. + Never, +} + +impl From for DismissChoice { + fn from(c: DismissChoiceDto) -> Self { + match c { + DismissChoiceDto::Later => Self::Later, + DismissChoiceDto::Never => Self::Never, + } + } +} + +/// Request DTO for `dismiss_embedder_suggestion`. The `project_id` is resolved to a +/// project root by the command; `choice` is the user's dismissal. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DismissEmbedderSuggestionRequestDto { + /// The project the suggestion concerned (UUID string). + pub project_id: String, + /// The user's choice. + pub choice: DismissChoiceDto, +} + +impl DismissEmbedderSuggestionRequestDto { + /// Builds the use-case input from a resolved project root + the DTO choice. + #[must_use] + pub fn into_input(self, project_root: domain::ProjectPath) -> DismissEmbedderSuggestionInput { + DismissEmbedderSuggestionInput { + project_root, + choice: self.choice.into(), + } + } +} + +/// Builds a [`DeleteProfileInput`] from a raw profile-id string. +/// +/// # Errors +/// [`ErrorDto`] with code `INVALID` if the id is malformed. +pub fn parse_delete_profile(raw: &str) -> Result { + Ok(DeleteProfileInput { + id: parse_profile_id(raw)?, + }) +} + +/// Parses a profile-id string (UUID) coming from the frontend. +/// +/// # Errors +/// Returns an [`ErrorDto`] with code `INVALID` if the string is not a UUID. +pub fn parse_profile_id(raw: &str) -> Result { + uuid::Uuid::parse_str(raw) + .map(ProfileId::from_uuid) + .map_err(|_| ErrorDto { + code: "INVALID".to_owned(), + message: format!("invalid profile id: {raw}"), + }) +} + +// --------------------------------------------------------------------------- +// Agents (L6) +// --------------------------------------------------------------------------- + +use application::{ + ChangeAgentProfileOutput, CreateAgentOutput, InspectConversationOutput, LaunchAgentOutput, + ListAgentsOutput, ReadAgentContextOutput, +}; +use domain::{Agent, EffectivePermissions, PermissionSet, ProjectPermissions, TerminalSession}; + +/// An agent crossing the wire. [`Agent`] already serialises camelCase +/// (`id`, `name`, `contextPath`, `profileId`, `origin` tagged, `synchronized`), +/// so we embed it directly — the TS mirror matches this shape. +#[derive(Debug, Clone, Serialize)] +#[serde(transparent)] +pub struct AgentDto(pub Agent); + +/// A list of agents (camelCase array on the wire). +#[derive(Debug, Clone, Serialize)] +#[serde(transparent)] +pub struct AgentListDto(pub Vec); + +impl From for AgentListDto { + fn from(out: ListAgentsOutput) -> Self { + Self(out.agents.into_iter().map(AgentDto).collect()) + } +} + +impl From for AgentDto { + fn from(out: CreateAgentOutput) -> Self { + Self(out.agent) + } +} + +/// Request DTO for `create_agent`. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateAgentRequestDto { + /// Id of the owning project. + pub project_id: String, + /// Display name for the new agent. + pub name: String, + /// Runtime profile id. + pub profile_id: String, + /// Initial Markdown content (empty when absent). + #[serde(default)] + pub initial_content: Option, +} + +/// Response DTO for `read_agent_context`. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ReadAgentContextResponseDto { + /// The agent's Markdown context content. + pub content: String, +} + +impl From for ReadAgentContextResponseDto { + fn from(out: ReadAgentContextOutput) -> Self { + Self { + content: out.content.into_string(), + } + } +} + +/// Request DTO for `update_agent_context`. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateAgentContextRequestDto { + /// Id of the owning project. + pub project_id: String, + /// Id of the agent to update. + pub agent_id: String, + /// New Markdown content. + pub content: String, +} + +// --------------------------------------------------------------------------- +// Permissions (LP1) +// --------------------------------------------------------------------------- + +/// Full project permission document crossing the wire. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(transparent)] +pub struct ProjectPermissionsDto(pub ProjectPermissions); + +/// Effective permissions crossing the wire. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(transparent)] +pub struct EffectivePermissionsDto(pub EffectivePermissions); + +/// Request DTO for updating project default permissions. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateProjectPermissionsRequestDto { + /// Id of the owning project. + pub project_id: String, + /// New project defaults. `null` removes defaults. + pub permissions: Option, +} + +/// Request DTO for updating one agent permission override. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateAgentPermissionsRequestDto { + /// Id of the owning project. + pub project_id: String, + /// Target agent id. + pub agent_id: String, + /// New override. `null` removes the override. + pub permissions: Option, +} + +/// Request DTO for resolving one agent's effective permissions. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ResolveAgentPermissionsRequestDto { + /// Id of the owning project. + pub project_id: String, + /// Target agent id. + pub agent_id: String, +} + +/// Request DTO for `update_project_context`. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateProjectContextRequestDto { + /// Id of the owning project. + pub project_id: String, + /// New project-level Markdown context, stored under `.ideai/CONTEXT.md`. + pub content: String, +} + +/// Request DTO for `launch_agent`. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LaunchAgentRequestDto { + /// Id of the owning project. + pub project_id: String, + /// Id of the agent to launch. + pub agent_id: String, + /// Initial terminal height in rows. + pub rows: u16, + /// Initial terminal width in columns. + pub cols: u16, + /// Persistent CLI conversation id currently recorded on the hosting cell, if + /// any. `Some` ⇒ the launch resumes it; absent/`None` ⇒ a fresh cell (the + /// launch may assign a new id when the profile supports it). + #[serde(default)] + pub conversation_id: Option, + /// The layout leaf (node) hosting this launch. Enforces the "one live session + /// per agent" invariant: a launch into a node different from where the agent + /// is already running is refused (`AGENT_ALREADY_RUNNING`); the same node is + /// idempotent. Absent ⇒ a fresh node is minted (and any already-live agent is + /// refused). + #[serde(default)] + pub node_id: Option, +} + +impl From for TerminalSessionDto { + fn from(out: LaunchAgentOutput) -> Self { + let assigned_conversation_id = out.assigned_conversation_id; + let engine_session_id = out.engine_session_id; + // §17.6: the cell kind is *derived* from the launch routing. A structured + // descriptor (`structured: Some(..)`) means LaunchAgent routed to an + // AgentSession ⇒ chat cell; its absence means the PTY/terminal path ⇒ + // terminal cell. Single source of truth, no layout migration. + let cell_kind = if out.structured.is_some() { + CellKind::Chat + } else { + CellKind::Pty + }; + let s = out.session; + Self { + session_id: s.id.to_string(), + cwd: s.cwd.as_str().to_owned(), + rows: s.pty_size.rows, + cols: s.pty_size.cols, + assigned_conversation_id, + engine_session_id, + cell_kind, + } + } +} + +/// Request DTO for `interrupt_agent` (cadrage C4 §4.2): the Interrompre path. The +/// frontend sends `{ request: { projectId, agentId } }`. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct InterruptAgentRequestDto { + /// Id of the owning project. + pub project_id: String, + /// Id of the agent whose running turn to preempt. + pub agent_id: String, +} + +/// Request DTO for `delegation_delivered` (ARCHITECTURE §20.3): the frontend write- +/// portal acks that it **physically wrote** a delegation `ticket` into the agent's +/// native PTY. Best-effort observability — it never changes correlation (the `ask` is +/// still woken by `idea_reply`). The frontend sends `{ request: { projectId, agentId, +/// ticket } }`. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DeliveredDelegationRequestDto { + /// Id of the owning project. + pub project_id: String, + /// Id of the agent whose terminal received the delegation. + pub agent_id: String, + /// Id of the delivered mailbox ticket. + pub ticket: String, +} + +/// Request DTO for `set_front_attached`: the write-portal of an agent cell reports +/// whether a **frontend terminal cell is mounted** for `agentId` (`true` on mount, +/// `false` on unmount). The mediator uses it to choose, at delivery time, between +/// publishing `DelegationReady` (a cell will write it) and writing the turn into the +/// PTY itself (headless/background-delegated agent with no cell). The frontend sends +/// `{ request: { agentId, attached } }`. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FrontAttachedRequestDto { + /// Id of the agent whose terminal cell mounted/unmounted. + pub agent_id: String, + /// `true` when the cell's write-portal is now active, `false` on teardown. + pub attached: bool, +} + +/// Request DTO for `change_agent_profile` (§15.1): hot-swap an agent's runtime +/// profile, optionally relaunching its live session in place. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ChangeAgentProfileRequestDto { + /// Id of the owning project. + pub project_id: String, + /// Id of the agent whose runtime profile to change. + pub agent_id: String, + /// Id of the new runtime profile. + pub profile_id: String, + /// Terminal height in rows for a possible hot relaunch. + pub rows: u16, + /// Terminal width in columns for a possible hot relaunch. + pub cols: u16, +} + +/// Response DTO for `change_agent_profile`: the mutated agent plus the freshly +/// relaunched session when a live session was hot-swapped (absent otherwise). +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ChangeAgentProfileDto { + /// The agent now carrying the new profile. + pub agent: AgentDto, + /// The relaunched session, present only when a live session was swapped. + #[serde(skip_serializing_if = "Option::is_none")] + pub relaunched_session: Option, +} + +impl From for ChangeAgentProfileDto { + fn from(out: ChangeAgentProfileOutput) -> Self { + Self { + agent: AgentDto(out.agent), + relaunched_session: out.relaunched.map(TerminalSessionDto::from), + } + } +} + +impl From for TerminalSessionDto { + fn from(s: TerminalSession) -> Self { + Self { + session_id: s.id.to_string(), + cwd: s.cwd.as_str().to_owned(), + rows: s.pty_size.rows, + cols: s.pty_size.cols, + assigned_conversation_id: None, + engine_session_id: None, + cell_kind: CellKind::Pty, + } + } +} + +// --------------------------------------------------------------------------- +// Structured chat sessions (§17 — D4) +// --------------------------------------------------------------------------- + +/// One incremental chunk of a structured agent reply, streamed over the chat +/// session's adapter-owned channel. The serialised wire twin of a +/// [`domain::ports::ReplyEvent`]: the `agent_send` pump maps each turn event to +/// one of these and pushes it to the frontend `AgentChatView`. +/// +/// Tagged on `kind` (camelCase: `"textDelta"` | `"toolActivity"` | `"final"` | +/// `"error"`), so the front branches without positional parsing. `Final` is the +/// normal deterministic terminal chunk of a turn; `Error` is a visible terminal +/// fallback for an otherwise silent/empty turn. `Deserialize` is derived too so +/// tests (and a mock gateway) can round-trip it. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "camelCase")] +pub enum ReplyChunk { + /// An assistant text fragment (incremental chat rendering). + #[serde(rename_all = "camelCase")] + TextDelta { + /// The text fragment. + text: String, + }, + /// A human-readable tool-activity badge (best-effort observability). + #[serde(rename_all = "camelCase")] + ToolActivity { + /// The human-readable activity label. + label: String, + }, + /// The deterministic end-of-turn chunk carrying the aggregated final content. + #[serde(rename_all = "camelCase")] + Final { + /// The aggregated final content of the turn. + content: String, + }, + /// A visible terminal fallback when the model stream produced no usable answer. + #[serde(rename_all = "camelCase")] + Error { + /// Human-readable explanation to render in the chat cell. + message: String, + }, +} + +/// Response DTO for `reattach_agent_chat`: the retained **conversation +/// scrollback** of a still-live structured session, replayed into the +/// re-mounting `AgentChatView` before the new reply stream is wired (§17.6). The +/// typed twin of [`ReattachResultDto`] (PTY scrollback bytes) — here the +/// scrollback is the ordered list of [`ReplyChunk`]s already streamed. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ReattachChatDto { + /// The session that was re-attached (echoed back for the frontend). + pub session_id: String, + /// The chunks already streamed for this conversation, in order. The frontend + /// replays them to rebuild the visible turns, then receives subsequent chunks + /// over the freshly-registered channel. + pub scrollback: Vec, +} + +/// Request DTO for `inspect_conversation` (T7). +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct InspectConversationRequestDto { + /// Id of the owning project. + pub project_id: String, + /// Id of the agent whose conversation is inspected. + pub agent_id: String, + /// The conversation id recorded on the hosting cell. + pub conversation_id: String, +} + +/// Response DTO for `inspect_conversation` (T7): the best-effort enriched +/// details for a resume popup. Both fields are optional and **omitted from the +/// wire when `None`** (`skip_serializing_if`), so the TypeScript side sees an +/// absent key — not `null` — for a degraded inspection. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ConversationDetailsDto { + /// A short, best-effort label for the conversation (last user message, + /// truncated). Absent when it could not be extracted. + #[serde(skip_serializing_if = "Option::is_none")] + pub last_topic: Option, + /// A best-effort cumulative token count. Absent when no usage info exists. + #[serde(skip_serializing_if = "Option::is_none")] + pub token_count: Option, +} + +impl From for ConversationDetailsDto { + fn from(out: InspectConversationOutput) -> Self { + Self { + last_topic: out.details.last_topic, + token_count: out.details.token_count, + } + } +} + +/// One currently-live agent and the cell hosting it, for `list_live_agents`. +/// +/// Lets the UI disable an agent already running in another cell (it cannot be +/// launched a second time — the "one live session per agent" invariant). +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LiveAgentDto { + /// The live agent's id (UUID string). + pub agent_id: String, + /// The hosting layout leaf's node id (UUID string). + pub node_id: String, + /// The live PTY session id, used to reattach a newly-opened cell without + /// respawning the agent. + pub session_id: String, + /// Runtime family that owns the session (`pty`/`structured`). + pub kind: LiveWorkSessionKindDto, +} + +/// Response DTO for `list_live_agents` (transparent array on the wire). +#[derive(Debug, Clone, Serialize)] +#[serde(transparent)] +pub struct LiveAgentListDto(pub Vec); + +impl LiveAgentListDto { + /// Builds the wire list from the registry's live-session snapshots. + /// + /// De-duplicates by `agent_id`: the "one live session per agent" invariant + /// guarantees an agent is live in at most one registry (PTY **or** + /// structured), but the aggregated input could in theory carry the same agent + /// twice; the UI contract is a dup-free set (each agent appears once), so we + /// keep the first occurrence and drop any later one for the same agent. + #[must_use] + pub fn from_snapshots(pairs: Vec) -> Self { + let mut seen = std::collections::HashSet::new(); + Self( + pairs + .into_iter() + .filter(|snapshot| seen.insert(snapshot.agent_id)) + .map(|snapshot| LiveAgentDto { + agent_id: snapshot.agent_id.to_string(), + node_id: snapshot.node_id.to_string(), + session_id: snapshot.session_id.to_string(), + kind: snapshot.kind.into(), + }) + .collect(), + ) + } +} + +/// Runtime family of a live work-state session. +#[derive(Debug, Clone, Copy, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum LiveWorkSessionKindDto { + /// Raw PTY-backed CLI session. + Pty, + /// Structured agent-session backend. + Structured, +} + +impl From for LiveWorkSessionKindDto { + fn from(kind: LiveSessionKind) -> Self { + match kind { + LiveSessionKind::Pty => Self::Pty, + LiveSessionKind::Structured => Self::Structured, + } + } +} + +/// Live session coordinates in the project work-state read model. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LiveWorkSessionDto { + /// The layout node currently hosting the session view. + pub node_id: String, + /// The live session id. + pub session_id: String, + /// Runtime family that owns the session. + pub kind: LiveWorkSessionKindDto, +} + +/// Derived processing status of a queued ticket. +#[derive(Debug, Clone, Copy, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum TicketWorkStatusDto { + /// The agent's current busy turn is running this ticket. + InProgress, + /// Waiting behind the head / the agent is idle. + Queued, +} + +impl From for TicketWorkStatusDto { + fn from(status: TicketWorkStatus) -> Self { + match status { + TicketWorkStatus::InProgress => Self::InProgress, + TicketWorkStatus::Queued => Self::Queued, + } + } +} + +/// Origin of a queued ticket (human operator or a delegating agent). +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase", tag = "kind")] +pub enum TicketWorkSourceDto { + /// The human operator. + Human, + /// Another agent delegating via `idea_ask_agent`. + #[serde(rename_all = "camelCase")] + Agent { + /// The delegating agent id. + agent_id: String, + }, +} + +impl From for TicketWorkSourceDto { + fn from(source: TicketWorkSource) -> Self { + match source { + TicketWorkSource::Human => Self::Human, + TicketWorkSource::Agent { agent_id } => Self::Agent { + agent_id: agent_id.to_string(), + }, + } + } +} + +/// One queued/in-progress delegation ticket for an agent. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentTicketStateDto { + /// Stable id of the queued ticket. + pub ticket_id: String, + /// Conversation thread this task enters. + pub conversation_id: String, + /// FIFO position at snapshot time (`0` = head). + pub position: u32, + /// Derived status (in-progress vs queued). + pub status: TicketWorkStatusDto, + /// Origin of the ticket. + pub source: TicketWorkSourceDto, + /// Display label of the requester. + pub requester_label: String, + /// Bounded excerpt of the task. + pub task_preview: String, + /// Character length of the original (un-truncated) task. + pub task_len: usize, +} + +impl From for AgentTicketStateDto { + fn from(ticket: AgentTicketState) -> Self { + Self { + ticket_id: ticket.ticket_id.to_string(), + conversation_id: ticket.conversation_id.to_string(), + position: ticket.position, + status: ticket.status.into(), + source: ticket.source.into(), + requester_label: ticket.requester_label, + task_preview: ticket.task_preview, + task_len: ticket.task_len, + } + } +} + +/// One background task owned by an agent in the Work panel read model. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentBackgroundTaskStateDto { + /// Stable task id (UUID string). + pub task_id: String, + /// Kind discriminant. + pub kind: String, + /// Lifecycle state. + pub state: String, + /// Process exit code, when the terminal result carries one. + #[serde(skip_serializing_if = "Option::is_none")] + pub exit_code: Option, + /// Human-readable summary / error / reason of the terminal result. + #[serde(skip_serializing_if = "Option::is_none")] + pub summary: Option, + /// Bounded stdout tail. + #[serde(skip_serializing_if = "Option::is_none")] + pub stdout_tail: Option, + /// Bounded stderr tail. + #[serde(skip_serializing_if = "Option::is_none")] + pub stderr_tail: Option, + /// Creation timestamp, epoch milliseconds. + pub created_at_ms: u64, + /// Last update timestamp, epoch milliseconds. + pub updated_at_ms: u64, +} + +impl From for AgentBackgroundTaskStateDto { + fn from(task: AgentBackgroundTaskState) -> Self { + Self { + task_id: task.task_id.to_string(), + kind: background_kind_label_from_work_state(task.kind).to_owned(), + state: background_state_label(task.state).to_owned(), + exit_code: task.exit_code, + summary: task.summary, + stdout_tail: task.stdout_tail, + stderr_tail: task.stderr_tail, + created_at_ms: task.created_at_ms, + updated_at_ms: task.updated_at_ms, + } + } +} + +/// One manifest agent's current live/busy state. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentWorkStateDto { + /// Agent id. + pub agent_id: String, + /// Agent display name. + pub name: String, + /// Runtime profile id assigned to the agent. + pub profile_id: String, + /// Live session, if any. + pub live: Option, + /// Current mediated-input busy state. + pub busy: AgentBusyState, + /// Pending/in-progress delegation tickets, in FIFO order. + pub tickets: Vec, + /// Best-effort first-class background tasks owned by this agent. + pub background_tasks: Vec, +} + +/// How much of a [`ConversationWorkSummaryDto`] could be derived, best-effort. +#[derive(Debug, Clone, Copy, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum ConversationPreviewStatusDto { + /// A handoff was present and usable. + Ready, + /// No handoff yet; `recentTurns` may carry the log fallback. + Missing, + /// The handoff was unreadable but the log fallback was readable. + Partial, + /// Neither the handoff nor the log could be read. + Unavailable, +} + +impl From for ConversationPreviewStatusDto { + fn from(status: ConversationPreviewStatus) -> Self { + match status { + ConversationPreviewStatus::Ready => Self::Ready, + ConversationPreviewStatus::Missing => Self::Missing, + ConversationPreviewStatus::Partial => Self::Partial, + ConversationPreviewStatus::Unavailable => Self::Unavailable, + } + } +} + +/// One recent turn surfaced in a conversation summary's log fallback. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ConversationTurnWorkPreviewDto { + /// Nature of the turn (`prompt`/`response`/`toolActivity`). + pub role: TurnRole, + /// Origin of the turn (human operator or a delegating agent). + pub source: TicketWorkSourceDto, + /// Timestamp (epoch milliseconds) of the turn. + pub at_ms: u64, + /// Bounded excerpt of the turn text. + pub text_preview: String, + /// Character length of the original (un-truncated) turn text. + pub text_len: usize, +} + +impl From for ConversationTurnWorkPreviewDto { + fn from(turn: ConversationTurnWorkPreview) -> Self { + Self { + role: turn.role, + source: turn.source.into(), + at_ms: turn.at_ms, + text_preview: turn.text_preview, + text_len: turn.text_len, + } + } +} + +/// Best-effort, read-only summary of one conversation visible through the tickets. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ConversationWorkSummaryDto { + /// The conversation (pair) this summary describes. + pub conversation_id: String, + /// How much could be derived. + pub status: ConversationPreviewStatusDto, + /// Bounded excerpt of the handoff objective, when present. + pub objective_preview: Option, + /// Bounded excerpt of the handoff summary, when present. + pub summary_preview: Option, + /// Character length of the original (un-truncated) handoff summary (`0` when none). + pub summary_len: usize, + /// Cursor (last turn id) covered by the handoff summary, when present. + pub up_to: Option, + /// Bounded, recent turns from the log fallback. + pub recent_turns: Vec, +} + +impl From for ConversationWorkSummaryDto { + fn from(summary: ConversationWorkSummary) -> Self { + Self { + conversation_id: summary.conversation_id.to_string(), + status: summary.status.into(), + objective_preview: summary.objective_preview, + summary_preview: summary.summary_preview, + summary_len: summary.summary_len, + up_to: summary.up_to.map(|cursor| cursor.to_string()), + recent_turns: summary + .recent_turns + .into_iter() + .map(ConversationTurnWorkPreviewDto::from) + .collect(), + } + } +} + +/// Project-level read model for conversation/delegation UX. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ProjectWorkStateDto { + /// Manifest agents in manifest order, enriched with live/busy state. + pub agents: Vec, + /// Best-effort summaries of the conversations referenced by the tickets, + /// joined frontend-side via `tickets[].conversationId`. + pub conversations: Vec, +} + +impl From for ProjectWorkStateDto { + fn from(state: ProjectWorkState) -> Self { + Self { + agents: state + .agents + .into_iter() + .map(|agent| AgentWorkStateDto { + agent_id: agent.agent_id.to_string(), + name: agent.name, + profile_id: agent.profile_id.to_string(), + live: agent.live.map(|live| LiveWorkSessionDto { + node_id: live.node_id.to_string(), + session_id: live.session_id.to_string(), + kind: live.kind.into(), + }), + busy: agent.busy, + tickets: agent + .tickets + .into_iter() + .map(AgentTicketStateDto::from) + .collect(), + background_tasks: agent + .background_tasks + .into_iter() + .map(AgentBackgroundTaskStateDto::from) + .collect(), + }) + .collect(), + conversations: state + .conversations + .into_iter() + .map(ConversationWorkSummaryDto::from) + .collect(), + } + } +} + +/// Request DTO for `attach_live_agent`: bind an already-running agent session to +/// a visible layout cell without spawning a new process. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AttachLiveAgentRequestDto { + /// Id of the owning project (validated for symmetry and future scoping). + pub project_id: String, + /// Id of the already-running agent. + pub agent_id: String, + /// Layout leaf that should display the live session. + pub node_id: String, +} + +/// Response DTO for `attach_live_agent`: the rebound live session's coordinates. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AttachLiveAgentResponseDto { + /// The rebound agent's id. + pub agent_id: String, + /// The node now hosting the session view. + pub node_id: String, + /// The (unchanged) live session id. + pub session_id: String, + /// Runtime family that owns the session (`pty`/`structured`). + pub kind: LiveWorkSessionKindDto, +} + +impl From for AttachLiveAgentResponseDto { + fn from(out: AttachLiveAgentOutput) -> Self { + Self { + agent_id: out.agent_id.to_string(), + node_id: out.node_id.to_string(), + session_id: out.session_id.to_string(), + kind: out.kind.into(), + } + } +} + +/// Request DTO for `stop_live_agent`: tear down an already-running agent's live +/// session by agent id (no spawn, agent entity preserved). +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct StopLiveAgentRequestDto { + /// Id of the owning project (validated for symmetry and future scoping). + pub project_id: String, + /// Id of the running agent to stop. + pub agent_id: String, +} + +/// Response DTO for `stop_live_agent`: the torn-down session's coordinates. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct StopLiveAgentResponseDto { + /// The stopped agent's id. + pub agent_id: String, + /// The id of the session that was torn down. + pub session_id: String, + /// Runtime family that owned the session (`pty`/`structured`). + pub kind: LiveWorkSessionKindDto, +} + +impl From for StopLiveAgentResponseDto { + fn from(out: StopLiveAgentOutput) -> Self { + Self { + agent_id: out.agent_id.to_string(), + session_id: out.session_id.to_string(), + kind: out.kind.into(), + } + } +} + +/// Parses an agent-id string (UUID) coming from the frontend. +/// +/// # Errors +/// Returns an [`ErrorDto`] with code `INVALID` if the string is not a UUID. +pub fn parse_agent_id(raw: &str) -> Result { + uuid::Uuid::parse_str(raw) + .map(AgentId::from_uuid) + .map_err(|_| ErrorDto { + code: "INVALID".to_owned(), + message: format!("invalid agent id: {raw}"), + }) +} + +/// Parses a ticket-id string (UUID) coming from the frontend (`delegation_delivered`). +/// +/// # Errors +/// Returns an [`ErrorDto`] with code `INVALID` if the string is not a UUID. +pub fn parse_ticket_id(raw: &str) -> Result { + uuid::Uuid::parse_str(raw) + .map(domain::TicketId::from_uuid) + .map_err(|_| ErrorDto { + code: "INVALID".to_owned(), + message: format!("invalid ticket id: {raw}"), + }) +} + +// --------------------------------------------------------------------------- +// Resumable agents (§15.2 — Chantier B2) +// --------------------------------------------------------------------------- + +use application::{ListResumableAgentsOutput, ResumableAgent}; + +/// One resumable agent cell, as seen by the frontend (§15.2). +/// +/// Mirrors [`ResumableAgent`]: the agent's identity + its host cell, the CLI +/// conversation id to resume (absent ⇒ fresh relaunch), the `was_running` flag +/// frozen at close, and whether the agent's profile can resume a CLI +/// conversation. `conversationId` is **omitted from the wire when `None`** +/// (`skip_serializing_if`), so the TypeScript side sees an absent key — not +/// `null`. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ResumableAgentDto { + /// The resumable agent's id (UUID string). + pub agent_id: String, + /// The agent's display name (resolved from the manifest). + pub name: String, + /// The host layout leaf where the agent is relaunched/resumed (UUID string). + pub node_id: String, + /// Persistent CLI conversation id carried by the cell. Absent ⇒ fresh + /// relaunch (no history to resume). + #[serde(skip_serializing_if = "Option::is_none")] + pub conversation_id: Option, + /// The `agent_was_running` flag frozen at the cell's close. + pub was_running: bool, + /// `true` when the agent's profile carries a usable resume strategy. + pub resume_supported: bool, +} + +impl From for ResumableAgentDto { + fn from(r: ResumableAgent) -> Self { + Self { + agent_id: r.agent_id.to_string(), + name: r.name, + node_id: r.node_id.to_string(), + conversation_id: r.conversation_id, + was_running: r.was_running, + resume_supported: r.resume_supported, + } + } +} + +/// Response DTO for `list_resumable_agents` (§15.2). +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ResumableAgentListDto { + /// The resumable agent cells, in layout-traversal order. + pub resumable: Vec, +} + +impl From for ResumableAgentListDto { + fn from(out: ListResumableAgentsOutput) -> Self { + Self { + resumable: out + .resumable + .into_iter() + .map(ResumableAgentDto::from) + .collect(), + } + } +} + +// --------------------------------------------------------------------------- +// Templates & sync (L7) +// --------------------------------------------------------------------------- + +use application::{ + AgentDrift, CreateAgentFromTemplateInput, CreateTemplateInput, CreateTemplateOutput, + DetectAgentDriftOutput, ListTemplatesOutput, SyncAgentWithTemplateOutput, UpdateTemplateInput, + UpdateTemplateOutput, +}; +use domain::{AgentTemplate, TemplateId}; + +/// A template crossing the wire. [`AgentTemplate`] already serialises camelCase +/// (`id`, `name`, `contentMd`, `version` as a number, `defaultProfileId`), +/// so we embed it directly — the TS mirror matches this shape. +#[derive(Debug, Clone, Serialize)] +#[serde(transparent)] +pub struct TemplateDto(pub AgentTemplate); + +/// A list of templates (transparent array on the wire). +#[derive(Debug, Clone, Serialize)] +#[serde(transparent)] +pub struct TemplateListDto(pub Vec); + +impl From for TemplateListDto { + fn from(out: ListTemplatesOutput) -> Self { + Self(out.templates.into_iter().map(TemplateDto).collect()) + } +} + +impl From for TemplateDto { + fn from(out: CreateTemplateOutput) -> Self { + Self(out.template) + } +} + +impl From for TemplateDto { + fn from(out: UpdateTemplateOutput) -> Self { + Self(out.template) + } +} + +/// Request DTO for `create_template`. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateTemplateRequestDto { + /// Display name. + pub name: String, + /// Initial Markdown content. + pub content: String, + /// Default runtime profile id for agents created from this template. + pub default_profile_id: String, +} + +impl CreateTemplateRequestDto { + /// Converts to the use-case input, parsing the profile id. + /// + /// # Errors + /// [`ErrorDto`] with code `INVALID` if the profile id is malformed. + pub fn into_input(self) -> Result { + Ok(CreateTemplateInput { + name: self.name, + content: self.content, + default_profile_id: parse_profile_id(&self.default_profile_id)?, + }) + } +} + +/// Request DTO for `update_template`. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateTemplateRequestDto { + /// Id of the template to update. + pub template_id: String, + /// New Markdown content. + pub content: String, +} + +impl UpdateTemplateRequestDto { + /// Converts to the use-case input, parsing the template id. + /// + /// # Errors + /// [`ErrorDto`] with code `INVALID` if the template id is malformed. + pub fn into_input(self) -> Result { + Ok(UpdateTemplateInput { + template_id: parse_template_id(&self.template_id)?, + content: self.content, + }) + } +} + +/// Parses a template-id string (UUID) coming from the frontend. +/// +/// # Errors +/// Returns an [`ErrorDto`] with code `INVALID` if the string is not a UUID. +pub fn parse_template_id(raw: &str) -> Result { + uuid::Uuid::parse_str(raw) + .map(TemplateId::from_uuid) + .map_err(|_| ErrorDto { + code: "INVALID".to_owned(), + message: format!("invalid template id: {raw}"), + }) +} + +/// Request DTO for `create_agent_from_template`. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateAgentFromTemplateRequestDto { + /// Id of the owning project. + pub project_id: String, + /// Source template id. + pub template_id: String, + /// Optional agent name; defaults to the template's name when absent. + #[serde(default)] + pub name: Option, + /// Whether the agent tracks the template for future syncs. + pub synchronized: bool, +} + +impl CreateAgentFromTemplateRequestDto { + /// Converts to the use-case input, given the resolved project. + /// + /// # Errors + /// [`ErrorDto`] with code `INVALID` if the template id is malformed. + pub fn into_input( + self, + project: domain::Project, + ) -> Result { + Ok(CreateAgentFromTemplateInput { + project, + template_id: parse_template_id(&self.template_id)?, + name: self.name, + synchronized: self.synchronized, + }) + } +} + +/// One drifting agent, as seen by the frontend. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentDriftDto { + /// The drifting agent id (UUID string). + pub agent_id: String, + /// Version the agent is currently synced to. + pub from: u64, + /// Version available from the template. + pub to: u64, +} + +impl From for AgentDriftDto { + fn from(d: AgentDrift) -> Self { + Self { + agent_id: d.agent_id.to_string(), + from: d.from.get(), + to: d.to.get(), + } + } +} + +/// Response DTO for `detect_agent_drift` (transparent array on the wire). +#[derive(Debug, Clone, Serialize)] +#[serde(transparent)] +pub struct AgentDriftListDto(pub Vec); + +impl From for AgentDriftListDto { + fn from(out: DetectAgentDriftOutput) -> Self { + Self(out.drifts.into_iter().map(AgentDriftDto::from).collect()) + } +} + +/// Response DTO for `sync_agent_with_template`. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SyncResultDto { + /// Whether a sync was actually applied. + pub synced: bool, + /// The version the agent is now at (`null` when no sync happened). + pub version: Option, +} + +impl From for SyncResultDto { + fn from(out: SyncAgentWithTemplateOutput) -> Self { + Self { + synced: out.synced, + version: out.version.map(|v| v.get()), + } + } +} + +/// Request DTO for `sync_agent_with_template`. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SyncAgentWithTemplateRequestDto { + /// Id of the owning project. + pub project_id: String, + /// Id of the agent to sync. + pub agent_id: String, +} + +// --------------------------------------------------------------------------- +// Git (L8) +// --------------------------------------------------------------------------- + +use application::{GitBranchesOutput, GitCommitOutput, GitLogOutput, GitStatusOutput}; +use domain::ports::{GitCommitInfo, GitFileStatus, GraphCommit}; + +/// One changed path returned by `git_status`. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GitFileStatusDto { + /// Repo-relative path. + pub path: String, + /// Whether the change is staged. + pub staged: bool, +} + +impl From for GitFileStatusDto { + fn from(s: GitFileStatus) -> Self { + Self { + path: s.path, + staged: s.staged, + } + } +} + +/// Response DTO for `git_status` (transparent array on the wire). +#[derive(Debug, Clone, Serialize)] +#[serde(transparent)] +pub struct GitStatusListDto(pub Vec); + +impl From for GitStatusListDto { + fn from(out: GitStatusOutput) -> Self { + Self( + out.entries + .into_iter() + .map(GitFileStatusDto::from) + .collect(), + ) + } +} + +/// A single commit summary crossing the wire. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GitCommitDto { + /// Commit hash. + pub hash: String, + /// Commit message summary. + pub summary: String, +} + +impl From for GitCommitDto { + fn from(c: GitCommitInfo) -> Self { + Self { + hash: c.hash, + summary: c.summary, + } + } +} + +impl From for GitCommitDto { + fn from(out: GitCommitOutput) -> Self { + Self::from(out.commit) + } +} + +/// Response DTO for `git_log` (transparent array on the wire). +#[derive(Debug, Clone, Serialize)] +#[serde(transparent)] +pub struct GitCommitListDto(pub Vec); + +impl From for GitCommitListDto { + fn from(out: GitLogOutput) -> Self { + Self(out.commits.into_iter().map(GitCommitDto::from).collect()) + } +} + +/// Response DTO for `git_branches`. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GitBranchesDto { + /// All local branches. + pub branches: Vec, + /// The current branch (`null` when detached or unborn). + pub current: Option, +} + +impl From for GitBranchesDto { + fn from(out: GitBranchesOutput) -> Self { + Self { + branches: out.branches, + current: out.current, + } + } +} + +/// A single commit enriched for graph display. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GraphCommitDto { + /// Full commit hash. + pub hash: String, + /// First line of the commit message. + pub summary: String, + /// Parent commit hashes. + pub parents: Vec, + /// Ref labels pointing at this commit (e.g. `"main"`, `"tag: v1.0"`). + pub refs: Vec, + /// Author name. + pub author: String, + /// Author timestamp in Unix seconds. + pub timestamp: i64, +} + +impl From for GraphCommitDto { + fn from(c: GraphCommit) -> Self { + Self { + hash: c.hash, + summary: c.summary, + parents: c.parents, + refs: c.refs, + author: c.author, + timestamp: c.timestamp, + } + } +} + +/// Response DTO for `git_graph` (transparent array on the wire). +#[derive(Debug, Clone, Serialize)] +#[serde(transparent)] +pub struct GraphCommitListDto(pub Vec); + +impl From for GraphCommitListDto { + fn from(out: GitGraphOutput) -> Self { + Self(out.commits.into_iter().map(GraphCommitDto::from).collect()) + } +} + +/// Request DTO for `git_stage` / `git_unstage`. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GitStageRequestDto { + /// Id of the owning project. + pub project_id: String, + /// Repo-relative path to (un)stage. + pub path: String, +} + +/// Request DTO for `git_commit`. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GitCommitRequestDto { + /// Id of the owning project. + pub project_id: String, + /// Commit message. + pub message: String, +} + +/// Request DTO for `git_checkout`. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GitCheckoutRequestDto { + /// Id of the owning project. + pub project_id: String, + /// Branch to check out. + pub branch: String, +} + +// --------------------------------------------------------------------------- +// Windows (L10) +// --------------------------------------------------------------------------- + +use application::MoveTabToNewWindowOutput; +use domain::ids::TabId; + +/// Response DTO for `move_tab_to_new_window`: the id minted for the new window +/// (used as the new OS window's label). +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MoveTabResultDto { + /// The new window id (UUID string). + pub new_window_id: String, +} + +impl From for MoveTabResultDto { + fn from(out: MoveTabToNewWindowOutput) -> Self { + Self { + new_window_id: out.new_window_id.to_string(), + } + } +} + +/// Parses a tab-id string (UUID) coming from the frontend. +/// +/// # Errors +/// Returns an [`ErrorDto`] with code `INVALID` if the string is not a UUID. +pub fn parse_tab_id(raw: &str) -> Result { + uuid::Uuid::parse_str(raw) + .map(TabId::from_uuid) + .map_err(|_| ErrorDto { + code: "INVALID".to_owned(), + message: format!("invalid tab id: {raw}"), + }) +} + +// --------------------------------------------------------------------------- +// Skills (L12) +// --------------------------------------------------------------------------- + +use application::{CreateSkillOutput, ListSkillsOutput, UpdateSkillOutput}; +use domain::{Skill, SkillId, SkillScope}; + +/// A skill crossing the wire. [`Skill`] already serialises camelCase +/// (`id`, `name`, `contentMd`, `scope` as `"global"`/`"project"`), so we embed +/// it directly — the TS mirror matches this shape. +#[derive(Debug, Clone, Serialize)] +#[serde(transparent)] +pub struct SkillDto(pub Skill); + +/// A list of skills (transparent array on the wire). +#[derive(Debug, Clone, Serialize)] +#[serde(transparent)] +pub struct SkillListDto(pub Vec); + +impl From for SkillListDto { + fn from(out: ListSkillsOutput) -> Self { + Self(out.skills.into_iter().map(SkillDto).collect()) + } +} + +impl From for SkillDto { + fn from(out: CreateSkillOutput) -> Self { + Self(out.skill) + } +} + +impl From for SkillDto { + fn from(out: UpdateSkillOutput) -> Self { + Self(out.skill) + } +} + +/// Request DTO for `create_skill`. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateSkillRequestDto { + /// Owning project (resolved to a root; ignored on disk for `Global`). + pub project_id: String, + /// Display name. + pub name: String, + /// Initial Markdown content. + pub content: String, + /// Scope the skill is created in. + pub scope: SkillScope, +} + +/// Request DTO for `update_skill`. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateSkillRequestDto { + /// Owning project (resolved to a root; ignored on disk for `Global`). + pub project_id: String, + /// Id of the skill to update. + pub skill_id: String, + /// Scope the skill lives in. + pub scope: SkillScope, + /// New Markdown content. + pub content: String, +} + +/// Request DTO for `assign_skill_to_agent`. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AssignSkillRequestDto { + /// Owning project. + pub project_id: String, + /// Agent receiving the skill. + pub agent_id: String, + /// Skill to assign. + pub skill_id: String, + /// Scope of the skill (recorded alongside the ref). + pub scope: SkillScope, +} + +/// Request DTO for `unassign_skill_from_agent`. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UnassignSkillRequestDto { + /// Owning project. + pub project_id: String, + /// Agent losing the skill. + pub agent_id: String, + /// Skill to unassign. + pub skill_id: String, +} + +/// Parses a skill-id string (UUID) coming from the frontend. +/// +/// # Errors +/// Returns an [`ErrorDto`] with code `INVALID` if the string is not a UUID. +pub fn parse_skill_id(raw: &str) -> Result { + uuid::Uuid::parse_str(raw) + .map(SkillId::from_uuid) + .map_err(|_| ErrorDto { + code: "INVALID".to_owned(), + message: format!("invalid skill id: {raw}"), + }) +} + +// --------------------------------------------------------------------------- +// Memory (LOT A — §14.5.1) +// --------------------------------------------------------------------------- + +use application::{ + CreateMemoryOutput, GetMemoryOutput, ListMemoriesOutput, ReadMemoryIndexOutput, + RecallMemoryOutput, ResolveMemoryLinksOutput, UpdateMemoryOutput, +}; +use domain::{Memory, MemoryIndexEntry, MemorySlug, MemoryType}; + +/// A memory note crossing the wire. +/// +/// Built explicitly from [`Memory`] (its `body` is [`domain::MarkdownDoc`], not +/// directly a JSON string) so the TS mirror gets a flat camelCase shape. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MemoryDto { + /// The note's slug (its identity, also the file stem). + pub name: String, + /// One-line description (the index hook). + pub description: String, + /// The note's kind. + pub r#type: MemoryType, + /// Markdown body of the note. + pub content: String, +} + +impl From for MemoryDto { + fn from(memory: Memory) -> Self { + Self { + name: memory.frontmatter.name.as_str().to_owned(), + description: memory.frontmatter.description, + r#type: memory.frontmatter.r#type, + content: memory.body.into_string(), + } + } +} + +impl From for MemoryDto { + fn from(out: CreateMemoryOutput) -> Self { + Self::from(out.memory) + } +} + +impl From for MemoryDto { + fn from(out: UpdateMemoryOutput) -> Self { + Self::from(out.memory) + } +} + +impl From for MemoryDto { + fn from(out: GetMemoryOutput) -> Self { + Self::from(out.memory) + } +} + +/// A list of memory notes (transparent array on the wire). +#[derive(Debug, Clone, Serialize)] +#[serde(transparent)] +pub struct MemoryListDto(pub Vec); + +impl From for MemoryListDto { + fn from(out: ListMemoriesOutput) -> Self { + Self(out.memories.into_iter().map(MemoryDto::from).collect()) + } +} + +/// One row of the structured `MEMORY.md` index crossing the wire. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MemoryIndexEntryDto { + /// The note's slug. + pub slug: String, + /// The note's display title. + pub title: String, + /// The one-line hook (the frontmatter description). + pub hook: String, + /// The note's kind. + pub r#type: MemoryType, +} + +impl From for MemoryIndexEntryDto { + fn from(entry: MemoryIndexEntry) -> Self { + Self { + slug: entry.slug.as_str().to_owned(), + title: entry.title, + hook: entry.hook, + r#type: entry.r#type, + } + } +} + +/// The structured memory index (transparent array on the wire). +#[derive(Debug, Clone, Serialize)] +#[serde(transparent)] +pub struct MemoryIndexDto(pub Vec); + +impl From for MemoryIndexDto { + fn from(out: ReadMemoryIndexOutput) -> Self { + Self( + out.entries + .into_iter() + .map(MemoryIndexEntryDto::from) + .collect(), + ) + } +} + +impl From for MemoryIndexDto { + fn from(out: RecallMemoryOutput) -> Self { + Self( + out.entries + .into_iter() + .map(MemoryIndexEntryDto::from) + .collect(), + ) + } +} + +/// A note's resolved outgoing links — the target slugs (transparent array). +#[derive(Debug, Clone, Serialize)] +#[serde(transparent)] +pub struct MemoryLinksDto(pub Vec); + +impl From for MemoryLinksDto { + fn from(out: ResolveMemoryLinksOutput) -> Self { + Self( + out.links + .into_iter() + .map(|link| link.target.as_str().to_owned()) + .collect(), + ) + } +} + +/// Request DTO for `create_memory`. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateMemoryRequestDto { + /// Owning project (resolved to a root). + pub project_id: String, + /// Raw slug for the new note. + pub name: String, + /// One-line description (the index hook). + pub description: String, + /// The note's kind. + pub r#type: MemoryType, + /// Markdown body of the note. + pub content: String, +} + +/// Request DTO for `update_memory`. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateMemoryRequestDto { + /// Owning project (resolved to a root). + pub project_id: String, + /// Slug of the note to replace. + pub slug: String, + /// New description (the index hook). + pub description: String, + /// New kind. + pub r#type: MemoryType, + /// New Markdown body. + pub content: String, +} + +/// Request DTO for `recall_memory` (LOT B — §14.5.2). +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RecallMemoryRequestDto { + /// Owning project (resolved to a root). + pub project_id: String, + /// The recall query (often the agent's current working context). + pub text: String, + /// Approximate token budget bounding the recalled entries (`0` ⇒ empty). + pub token_budget: usize, +} + +/// Parses a memory slug string coming from the frontend. +/// +/// # Errors +/// Returns an [`ErrorDto`] with code `INVALID` if the string is not a valid +/// kebab-case slug. +pub fn parse_memory_slug(raw: &str) -> Result { + MemorySlug::new(raw).map_err(|_| ErrorDto { + code: "INVALID".to_owned(), + message: format!("invalid memory slug: {raw}"), + }) +} + +// ── Conversation pagination (lot LS6) ──────────────────────────────────────── + +/// Request DTO for `read_conversation_page` — a human, paginated transcript read. +/// +/// `anchor` is a turn id to paginate around (omit to start from a thread end); +/// `direction` is `"forward"` (towards newer) or `"backward"` (towards older, +/// default — the human view opens on the latest page); `limit` is clamped by the +/// domain to `[1, 200]` (omit/`0` ⇒ default 50). +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReadConversationPageRequestDto { + /// The owning project's id. + pub project_id: String, + /// The conversation (pair) id to read. + pub conversation_id: String, + /// Optional anchor turn id; omit to start from a thread end. + #[serde(default)] + pub anchor: Option, + /// Pagination direction (`"forward"` or `"backward"`); defaults to backward. + #[serde(default)] + pub direction: Option, + /// Requested page size (omit/`0` ⇒ default; clamped to `[1, 200]`). + #[serde(default)] + pub limit: Option, +} + +impl ReadConversationPageRequestDto { + /// Builds the domain [`PageCursor`] from the request (default direction backward, + /// an unparseable anchor degrades to `None` = a thread-end page). + #[must_use] + pub fn cursor(&self) -> PageCursor { + let direction = match self.direction.as_deref() { + Some(d) if d.eq_ignore_ascii_case("forward") => PageDirection::Forward, + _ => PageDirection::Backward, + }; + let anchor = self + .anchor + .as_deref() + .and_then(|raw| uuid::Uuid::parse_str(raw).ok()) + .map(domain::TurnId::from_uuid); + PageCursor { anchor, direction } + } +} + +/// Origin of a turn in the human transcript view (mirrors [`TurnSource`]). +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase", tag = "kind")] +pub enum TurnSourceDto { + /// The human operator. + Human, + /// Another agent (delegation via `idea_ask_agent`). + #[serde(rename_all = "camelCase")] + Agent { + /// The originating agent id. + agent_id: String, + }, +} + +impl From for TurnSourceDto { + fn from(source: TurnSource) -> Self { + match source { + TurnSource::Human => Self::Human, + TurnSource::Agent { agent_id } => Self::Agent { + agent_id: agent_id.to_string(), + }, + } + } +} + +/// One turn in the human transcript — **full text, never truncated**. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TurnViewDto { + /// Stable turn id (also the pagination anchor). + pub id: String, + /// Timestamp (epoch milliseconds). + pub at_ms: u64, + /// Turn nature (prompt, response, tool activity). + pub role: TurnRole, + /// Origin (human or delegating agent). + pub source: TurnSourceDto, + /// The turn's **complete** text (not truncated). + pub text: String, + /// Character length of the text. + pub text_len: usize, +} + +impl From for TurnViewDto { + fn from(turn: TurnView) -> Self { + Self { + id: turn.id.to_string(), + at_ms: turn.at_ms, + role: turn.role, + source: turn.source.into(), + text: turn.text, + text_len: turn.text_len, + } + } +} + +/// A page of the human transcript, oldest-to-newest. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TurnPageDto { + /// The page's turns, oldest to newest. + pub turns: Vec, + /// Whether more turns exist beyond the page in the travel direction. + pub has_more: bool, + /// Last turn id of the page (anchor for the next request), or `None` if empty. + pub next_anchor: Option, +} + +impl From for TurnPageDto { + fn from(page: TurnPage) -> Self { + Self { + turns: page.turns.into_iter().map(TurnViewDto::from).collect(), + has_more: page.has_more, + next_anchor: page.next_anchor.map(|id| id.to_string()), + } + } +} + +#[cfg(test)] +mod ls6_pagination_tests { + //! LS6 — DTO de la lecture humaine paginée : parsing du curseur + mapping riche + //! `TurnPage → TurnPageDto` (texte complet non borné, source Human/Agent, `has_more`, + //! `next_anchor`). C'est le « round-trip d'une page » testable sans `State` Tauri. + + use super::*; + use application::{TurnPage, TurnSource, TurnView}; + + fn tid(n: u128) -> domain::TurnId { + domain::TurnId::from_uuid(uuid::Uuid::from_u128(n)) + } + + #[test] + fn request_cursor_defaults_to_backward_and_parses_anchor() { + // Pas de direction ⇒ backward (la vue humaine ouvre sur la page la plus récente). + let req = ReadConversationPageRequestDto { + project_id: "p".into(), + conversation_id: "c".into(), + anchor: None, + direction: None, + limit: None, + }; + let cur = req.cursor(); + assert_eq!(cur.direction, PageDirection::Backward); + assert_eq!(cur.anchor, None); + + // Forward (insensible à la casse) + ancre UUID valide. + let u = uuid::Uuid::from_u128(9); + let req = ReadConversationPageRequestDto { + project_id: "p".into(), + conversation_id: "c".into(), + anchor: Some(u.to_string()), + direction: Some("Forward".into()), + limit: Some(10), + }; + let cur = req.cursor(); + assert_eq!(cur.direction, PageDirection::Forward); + assert_eq!(cur.anchor, Some(domain::TurnId::from_uuid(u))); + + // Ancre non-UUID ⇒ dégrade en None (page depuis un bout du fil), jamais d'erreur. + let req = ReadConversationPageRequestDto { + project_id: "p".into(), + conversation_id: "c".into(), + anchor: Some("not-a-uuid".into()), + direction: Some("backward".into()), + limit: None, + }; + assert_eq!(req.cursor().anchor, None); + } + + #[test] + fn turn_page_dto_round_trips_full_text_and_maps_sources() { + let big = "Z".repeat(40_000); + let page = TurnPage { + turns: vec![ + TurnView { + id: tid(1), + at_ms: 1_700_000_000_000, + role: TurnRole::Prompt, + source: TurnSource::Human, + text: big.clone(), + text_len: big.chars().count(), + }, + TurnView { + id: tid(2), + at_ms: 1_700_000_000_001, + role: TurnRole::Response, + source: TurnSource::Agent { + agent_id: domain::AgentId::from_uuid(uuid::Uuid::from_u128(7)), + }, + text: "réponse".to_owned(), + text_len: 7, + }, + ], + has_more: true, + next_anchor: Some(tid(2)), + }; + + let dto = TurnPageDto::from(page); + // Texte complet préservé (non borné) + text_len. + assert_eq!(dto.turns[0].text.chars().count(), 40_000); + assert_eq!(dto.turns[0].text_len, 40_000); + // next_anchor stringifié, has_more porté. + assert!(dto.has_more); + assert_eq!( + dto.next_anchor.as_deref(), + Some(tid(2).to_string().as_str()) + ); + + // Round-trip JSON (la « page » telle qu'envoyée au front) : camelCase + source. + let json = serde_json::to_value(&dto).unwrap(); + assert_eq!(json["hasMore"], serde_json::json!(true)); + assert_eq!(json["nextAnchor"], serde_json::json!(tid(2).to_string())); + assert_eq!( + json["turns"][0]["source"]["kind"], + serde_json::json!("human") + ); + assert_eq!( + json["turns"][1]["source"]["kind"], + serde_json::json!("agent") + ); + assert_eq!( + json["turns"][1]["source"]["agentId"], + serde_json::json!(uuid::Uuid::from_u128(7).to_string()) + ); + // Le texte intégral survit au passage JSON. + assert_eq!( + json["turns"][0]["text"].as_str().unwrap().chars().count(), + 40_000 + ); + } + + #[test] + fn empty_turn_page_dto_has_no_next_anchor() { + let dto = TurnPageDto::from(TurnPage { + turns: Vec::new(), + has_more: false, + next_anchor: None, + }); + assert!(dto.turns.is_empty() && !dto.has_more && dto.next_anchor.is_none()); + } +} + +// --------------------------------------------------------------------------- +// Background tasks (B8 — first-class background command) +// --------------------------------------------------------------------------- + +use domain::{ + BackgroundTask, BackgroundTaskKind, BackgroundTaskResult, BackgroundTaskState, TaskId, +}; + +/// One first-class background task as seen by the frontend (camelCase wire shape). +/// +/// Mirrors the persisted [`BackgroundTask`], flattening the terminal +/// [`BackgroundTaskResult`] into optional `exitCode` / `summary` / tails so the +/// UI has a single shape for every lifecycle state. Optional fields are omitted +/// from the wire when absent (`skip_serializing_if`). +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BackgroundTaskDto { + /// Stable task id (UUID string). + pub task_id: String, + /// Owning agent id (UUID string). + pub owner_agent_id: String, + /// Owning project id (UUID string). + pub project_id: String, + /// Kind discriminant (`"command"`, `"headlessRendezvous"`, `"sessionResume"`, + /// `"maintenance"`). + pub kind: String, + /// Lifecycle state (`"queued"`, `"running"`, `"waiting"`, `"completed"`, + /// `"failed"`, `"cancelled"`, `"expired"`). + pub state: String, + /// Process exit code, when the terminal result carries one. + #[serde(skip_serializing_if = "Option::is_none")] + pub exit_code: Option, + /// Human-readable summary / error / reason of the terminal result. + #[serde(skip_serializing_if = "Option::is_none")] + pub summary: Option, + /// Bounded stdout tail (merged output for PTY-backed commands). + #[serde(skip_serializing_if = "Option::is_none")] + pub stdout_tail: Option, + /// Bounded stderr tail (unset for PTY-backed commands, which merge streams). + #[serde(skip_serializing_if = "Option::is_none")] + pub stderr_tail: Option, + /// Creation timestamp, epoch milliseconds. + pub created_at_ms: u64, + /// Last update timestamp, epoch milliseconds. + pub updated_at_ms: u64, +} + +/// Kind discriminant string for a [`BackgroundTaskKind`]. +fn background_kind_label(kind: &BackgroundTaskKind) -> &'static str { + match kind { + BackgroundTaskKind::Command { .. } => "command", + BackgroundTaskKind::HeadlessRendezvous { .. } => "headlessRendezvous", + BackgroundTaskKind::SessionResume { .. } => "sessionResume", + BackgroundTaskKind::Maintenance { .. } => "maintenance", + } +} + +/// Kind discriminant string for a work-state [`BackgroundTaskKindLabel`]. +fn background_kind_label_from_work_state(kind: BackgroundTaskKindLabel) -> &'static str { + match kind { + BackgroundTaskKindLabel::Command => "command", + BackgroundTaskKindLabel::HeadlessRendezvous => "headlessRendezvous", + BackgroundTaskKindLabel::SessionResume => "sessionResume", + BackgroundTaskKindLabel::Maintenance => "maintenance", + } +} + +/// Lifecycle state string for a [`BackgroundTaskState`]. +fn background_state_label(state: BackgroundTaskState) -> &'static str { + match state { + BackgroundTaskState::Queued => "queued", + BackgroundTaskState::Running => "running", + BackgroundTaskState::Waiting => "waiting", + BackgroundTaskState::Completed => "completed", + BackgroundTaskState::Failed => "failed", + BackgroundTaskState::Cancelled => "cancelled", + BackgroundTaskState::Expired => "expired", + } +} + +impl From for BackgroundTaskDto { + fn from(task: BackgroundTask) -> Self { + let (exit_code, summary, stdout_tail, stderr_tail) = match &task.result { + Some(BackgroundTaskResult::Success { + exit_code, + summary, + stdout_tail, + stderr_tail, + .. + }) => ( + *exit_code, + Some(summary.clone()), + stdout_tail.clone(), + stderr_tail.clone(), + ), + Some(BackgroundTaskResult::Failure { + exit_code, + error, + stdout_tail, + stderr_tail, + .. + }) => ( + *exit_code, + Some(error.clone()), + stdout_tail.clone(), + stderr_tail.clone(), + ), + Some(BackgroundTaskResult::Cancelled { reason, .. }) + | Some(BackgroundTaskResult::Expired { reason, .. }) => { + (None, Some(reason.clone()), None, None) + } + None => (None, None, None, None), + }; + Self { + task_id: task.id.to_string(), + owner_agent_id: task.owner_agent_id.to_string(), + project_id: task.project_id.to_string(), + kind: background_kind_label(&task.kind).to_owned(), + state: background_state_label(task.state).to_owned(), + exit_code, + summary, + stdout_tail, + stderr_tail, + created_at_ms: task.created_at_ms, + updated_at_ms: task.updated_at_ms, + } + } +} + +/// Parses a task-id string (UUID) coming from the frontend. +/// +/// # Errors +/// Returns an [`ErrorDto`] with code `INVALID` if the string is not a UUID. +pub fn parse_task_id(raw: &str) -> Result { + uuid::Uuid::parse_str(raw) + .map(TaskId::from_uuid) + .map_err(|_| ErrorDto { + code: "INVALID".to_owned(), + message: format!("invalid task id: {raw}"), + }) +} + +/// Request DTO for `spawn_background_command`. +/// +/// Builds a command-backed [`BackgroundTask`]: the command line runs under the +/// project host's PTY and its completion is delivered to `ownerAgentId`. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SpawnBackgroundCommandRequestDto { + /// Owning project id (UUID string). + pub project_id: String, + /// Agent that owns completion delivery (UUID string). + pub owner_agent_id: String, + /// Human-facing label. + pub label: String, + /// Executable to run. + pub command: String, + /// Arguments. + #[serde(default)] + pub args: Vec, + /// Working directory (absolute path). + pub cwd: String, + /// Extra environment variables. + #[serde(default)] + pub env: Vec<(String, String)>, + /// When `true` (the default), wake the owner on completion; otherwise only + /// record it. + #[serde(default)] + pub record_only: bool, + /// Optional absolute deadline, epoch milliseconds. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub deadline_ms: Option, +} diff --git a/crates/backend/src/events.rs b/crates/backend/src/events.rs new file mode 100644 index 0000000..ef1805c --- /dev/null +++ b/crates/backend/src/events.rs @@ -0,0 +1,1314 @@ +//! Transport-neutral DTOs for domain events. +//! +//! The event relay itself belongs to each driving adapter. This module owns only +//! the stable JSON wire shape shared by Tauri IPC and the headless web server. + +use serde::Serialize; + +use domain::conversation::ConversationParty; +use domain::events::{DomainEvent, OrchestrationSource}; +use domain::input::AgentLiveness; +use domain::model_server::ModelServerLifecycleStatus; +use domain::{IssueLinkKind, IssuePriority, IssueStatus}; + +/// Name of the Tauri event carrying relayed [`DomainEvent`]s. +pub const DOMAIN_EVENT: &str = "domain://event"; +/// Dedicated Tauri event for local model-server status changes. +pub const MODEL_SERVER_STATUS_CHANGED: &str = "model_server_status_changed"; +/// Dedicated Tauri event for launch failures. +pub const AGENT_LAUNCH_FAILED: &str = "agent_launch_failed"; + +/// Model-server lifecycle status on the Tauri wire. +#[derive(Debug, Clone, Serialize)] +#[serde(tag = "state", rename_all = "camelCase")] +pub enum ModelServerStatusDto { + /// No server is configured for this profile. + NotConfigured, + /// Readiness probing is in progress. + Probing, + /// IdeA is starting the process. + Starting, + /// Server is downloading/preparing a remote model. + #[serde(rename_all = "camelCase")] + Downloading { + /// Downloaded bytes when known. + downloaded_bytes: Option, + /// Total bytes when known. + total_bytes: Option, + /// Completion percentage when known. + percent: Option, + /// Remote model source. + source: Option, + }, + /// Server is ready. + #[serde(rename_all = "camelCase")] + Ready { + /// `true` when an existing server was reused. + reused: bool, + }, + /// Server preparation failed. + #[serde(rename_all = "camelCase")] + Failed { + /// Stable failure code. + code: String, + /// Human-readable message. + message: String, + }, +} + +impl From<&ModelServerLifecycleStatus> for ModelServerStatusDto { + fn from(status: &ModelServerLifecycleStatus) -> Self { + match status { + ModelServerLifecycleStatus::NotConfigured => Self::NotConfigured, + ModelServerLifecycleStatus::Probing => Self::Probing, + ModelServerLifecycleStatus::Starting => Self::Starting, + ModelServerLifecycleStatus::Downloading { + downloaded_bytes, + total_bytes, + percent, + source, + } => Self::Downloading { + downloaded_bytes: *downloaded_bytes, + total_bytes: *total_bytes, + percent: *percent, + source: source.clone(), + }, + ModelServerLifecycleStatus::Ready { reused } => Self::Ready { reused: *reused }, + ModelServerLifecycleStatus::Failed { code, message } => Self::Failed { + code: code.clone(), + message: message.clone(), + }, + } + } +} + +/// Payload for [`MODEL_SERVER_STATUS_CHANGED`]. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ModelServerStatusChangedDto { + /// Local model server id. + pub server_id: String, + /// Lifecycle status. + pub status: ModelServerStatusDto, +} + +/// Payload for [`AGENT_LAUNCH_FAILED`]. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentLaunchFailedDto { + /// Agent id. + pub agent_id: String, + /// Failure cause namespace. + pub cause: String, + /// Stable error code. + pub code: String, + /// Human-readable message. + pub message: String, +} + +/// 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 launch failed before runtime session creation. + #[serde(rename_all = "camelCase")] + AgentLaunchFailed { + /// Agent id. + agent_id: String, + /// Failure cause namespace. + cause: String, + /// Stable error code. + code: String, + /// Human-readable message. + message: String, + }, + /// Local model-server status changed. + #[serde(rename_all = "camelCase")] + ModelServerStatusChanged { + /// Local model server id. + server_id: String, + /// Lifecycle status. + status: ModelServerStatusDto, + }, + /// 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, + /// Profile's submit delay in ms; `null` ⇒ the front default (~60 ms). + #[serde(skip_serializing_if = "Option::is_none")] + submit_delay_ms: Option, + }, + /// 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, + }, + /// 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, + }, + /// 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, + /// New sprint id. + to: Option, + /// 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, + }, + /// 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, + /// 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, + }, + /// 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, + }, + /// 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, + }, +} + +/// 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 for AgentLivenessDto { + fn from(liveness: AgentLiveness) -> Self { + match liveness { + AgentLiveness::Alive => Self::Alive, + AgentLiveness::Stalled => Self::Stalled, + } + } +} + +impl From 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::AgentLaunchFailed { + agent_id, + cause, + code, + message, + } => Self::AgentLaunchFailed { + agent_id: agent_id.to_string(), + cause: cause.clone(), + code: code.clone(), + message: message.clone(), + }, + DomainEvent::ModelServerStatusChanged { server_id, status } => { + Self::ModelServerStatusChanged { + server_id: server_id.to_string(), + status: status.into(), + } + } + 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(), + }, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use domain::ids::AgentId; + use domain::mailbox::TicketId; + use domain::{LocalModelServerId, ProjectId}; + use serde_json::json; + + fn agent(n: u128) -> AgentId { + AgentId::from_uuid(uuid::Uuid::from_u128(n)) + } + + fn server(n: u128) -> LocalModelServerId { + LocalModelServerId::from_uuid(uuid::Uuid::from_u128(n)) + } + + #[test] + fn model_server_status_changed_relays_ready_to_dto_and_wire() { + let dto = DomainEventDto::from(&DomainEvent::ModelServerStatusChanged { + server_id: server(35), + status: ModelServerLifecycleStatus::Ready { reused: true }, + }); + + let json = serde_json::to_value(&dto).expect("serialisable"); + assert_eq!(json["type"], "modelServerStatusChanged"); + assert_eq!(json["serverId"], server(35).to_string()); + assert_eq!(json["status"]["state"], "ready"); + assert_eq!(json["status"]["reused"], true); + } + + #[test] + fn model_server_status_changed_relays_downloading_to_dto_and_wire() { + let dto = DomainEventDto::from(&DomainEvent::ModelServerStatusChanged { + server_id: server(37), + status: ModelServerLifecycleStatus::Downloading { + downloaded_bytes: None, + total_bytes: None, + percent: None, + source: Some("Qwen/Qwen3-Coder-30B-A3B-Instruct-GGUF".to_owned()), + }, + }); + + let json = serde_json::to_value(&dto).expect("serialisable"); + assert_eq!(json["type"], "modelServerStatusChanged"); + assert_eq!(json["serverId"], server(37).to_string()); + assert_eq!(json["status"]["state"], "downloading"); + assert_eq!(json["status"]["downloadedBytes"], serde_json::Value::Null); + assert_eq!(json["status"]["totalBytes"], serde_json::Value::Null); + assert_eq!(json["status"]["percent"], serde_json::Value::Null); + assert_eq!( + json["status"]["source"], + "Qwen/Qwen3-Coder-30B-A3B-Instruct-GGUF" + ); + } + + #[test] + fn agent_launch_failed_relays_model_server_cause() { + let dto = DomainEventDto::from(&DomainEvent::AgentLaunchFailed { + agent_id: agent(36), + cause: "model_server".to_owned(), + code: "path_not_accessible".to_owned(), + message: "path not accessible: /models/missing.gguf".to_owned(), + }); + + let json = serde_json::to_value(&dto).expect("serialisable"); + assert_eq!(json["type"], "agentLaunchFailed"); + assert_eq!(json["agentId"], agent(36).to_string()); + assert_eq!(json["cause"], "model_server"); + assert_eq!(json["code"], "path_not_accessible"); + } + + /// 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"); + } +} diff --git a/crates/backend/src/lib.rs b/crates/backend/src/lib.rs index b8b699d..e06c9a0 100644 --- a/crates/backend/src/lib.rs +++ b/crates/backend/src/lib.rs @@ -83,6 +83,8 @@ use infrastructure::{ VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED, }; +pub mod dto; +pub mod events; pub mod mcp_endpoint; pub mod openai_tools; pub mod stream; diff --git a/crates/backend/src/mcp_endpoint.rs b/crates/backend/src/mcp_endpoint.rs index 06422ee..1e14984 100644 --- a/crates/backend/src/mcp_endpoint.rs +++ b/crates/backend/src/mcp_endpoint.rs @@ -159,7 +159,7 @@ pub fn mcp_endpoint(project_id: &ProjectId) -> McpEndpoint { /// `/tmp/.mount_*` qui disparaît au redémarrage ⇒ `ENOENT` au respawn du pont MCP. /// Hors AppImage `$APPIMAGE` est absent ⇒ on retombe sur `current_exe()`. `None` si /// aucun des deux n'est résolvable (ne devrait pas arriver) ⇒ déclaration minimale. -pub(crate) fn idea_exe_path() -> Option { +pub fn idea_exe_path() -> Option { std::env::var("APPIMAGE").ok().or_else(|| { std::env::current_exe() .ok() diff --git a/crates/web-server/Cargo.toml b/crates/web-server/Cargo.toml new file mode 100644 index 0000000..dc072d7 --- /dev/null +++ b/crates/web-server/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "web-server" +version = "0.3.0" +edition.workspace = true +license.workspace = true +rust-version.workspace = true +description = "IdeA headless HTTP/WebSocket driving adapter, reusable by desktop and server binaries." + +[lib] +name = "web_server" +path = "src/lib.rs" + +[[bin]] +name = "idea-serve" +path = "src/bin/idea-serve.rs" + +[dependencies] +backend = { workspace = true } +application = { workspace = true } +domain = { workspace = true } +tokio = { workspace = true, features = ["io-std", "rt", "net"] } +serde = { workspace = true } +serde_json = { workspace = true } +uuid = { workspace = true } +base64 = "0.22" +bytes = "1.11" +cookie = "0.18" +http = "1.4" +http-body-util = "0.1" + +[features] +vector-http = ["backend/vector-http"] +vector-onnx = ["backend/vector-onnx"] diff --git a/crates/web-server/src/bin/idea-serve.rs b/crates/web-server/src/bin/idea-serve.rs new file mode 100644 index 0000000..0adf2fc --- /dev/null +++ b/crates/web-server/src/bin/idea-serve.rs @@ -0,0 +1,5 @@ +use std::process::ExitCode; + +fn main() -> ExitCode { + web_server::run_from_args(std::env::args().skip(1).collect()) +} diff --git a/crates/web-server/src/lib.rs b/crates/web-server/src/lib.rs new file mode 100644 index 0000000..cc6eca7 --- /dev/null +++ b/crates/web-server/src/lib.rs @@ -0,0 +1,4140 @@ +//! Secure HTTP driving adapter for `idea --serve`. +//! +//! The shared backend core stays unaware of HTTP, cookies, origins and +//! WebSocket framing; this module owns the secure web driving adapter. + +use std::collections::HashSet; +use std::env; +use std::net::SocketAddr; +use std::path::{Path, PathBuf}; +use std::process::ExitCode; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; + +use backend::stream::{OutputBridge, OutputSink, OutputSinkError}; +use bytes::Bytes; +use cookie::{Cookie, SameSite}; +use domain::events::DomainEvent; +use domain::ports::BackgroundTaskPortError; +use http::header::{HeaderValue, CONTENT_TYPE, COOKIE, ORIGIN, SET_COOKIE}; +#[cfg(test)] +use http::Request; +use http::{HeaderMap, Method, Response, StatusCode, Uri}; +use http_body_util::{BodyExt, Full}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpListener; +use tokio::sync::{mpsc, oneshot}; +use tokio::task::JoinHandle; +use uuid::Uuid; + +use application::{ + CloseTerminalInput, GetProjectWorkStateInput, LaunchAgentInput, McpRuntime, OpenProjectInput, + ResizeTerminalInput, RotateConversationLogInput, WriteToTerminalInput, +}; +use domain::ports::PtyHandle; +use domain::{Project, SessionId}; + +use backend::dto::{ + parse_agent_id, parse_node_id, parse_project_id, parse_session_id, parse_task_id, + BackgroundTaskDto, ErrorDto, HealthRequestDto, HealthResponseDto, LaunchAgentRequestDto, + OpenTerminalRequestDto, ProjectDto, ProjectListDto, ProjectWorkStateDto, TerminalSessionDto, +}; +use backend::events::DomainEventDto; +type PtyChunk = Vec; +use backend::BackendCore; + +const DEFAULT_LISTEN: &str = "127.0.0.1:17373"; +const SESSION_COOKIE: &str = "idea_session"; +const WS_PATH: &str = "/api/ws"; +const WS_MAGIC: &str = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; +const WS_MAX_PAYLOAD: usize = 64 * 1024; +const WS_OUTPUT_BUFFER: usize = 512; + +type ResponseBody = Full; + +/// Runs the `idea --serve` subcommand from already-split CLI arguments. +pub fn run_from_args(args: Vec) -> ExitCode { + let config = match ServerConfig::from_args(args).and_then(|config| { + config.validate()?; + Ok(config) + }) { + Ok(config) => config, + Err(err) => { + eprintln!("idea --serve: {err}"); + return ExitCode::from(2); + } + }; + + let state = Arc::new(ServerState::new(config.clone())); + eprintln!("IdeA pairing code: {}", state.pairing_code()); + eprintln!( + "idea --serve: app data dir = {}", + config.app_data_dir.display() + ); + eprintln!("IdeA server listening on {}", config.listen); + + match tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + { + Ok(runtime) => match runtime.block_on(run_server(config, state)) { + Ok(()) => ExitCode::SUCCESS, + Err(err) => { + eprintln!("idea --serve: {err}"); + ExitCode::from(1) + } + }, + Err(err) => { + eprintln!("idea --serve: failed to build runtime: {err}"); + ExitCode::from(1) + } + } +} + +/// Configuration shared by the standalone and embedded web-server entry points. +#[derive(Clone, Debug)] +pub struct ServerConfig { + /// Address to bind. + pub listen: SocketAddr, + /// Exact public origin allowed by CORS/origin checks. + pub public_origin: Option, + /// Whether non-loopback binds are allowed. + pub allow_remote: bool, + /// Whether a trusted HTTPS reverse proxy terminates remote access. + pub trust_reverse_proxy: bool, + /// IdeA application data directory. + pub app_data_dir: PathBuf, + /// Built frontend assets root. + pub web_root: PathBuf, +} + +impl ServerConfig { + /// Parses the web-server CLI flags. + pub fn from_args(args: Vec) -> Result { + let mut listen = DEFAULT_LISTEN + .parse::() + .expect("default listen address is valid"); + let mut public_origin = None; + let mut allow_remote = false; + let mut trust_reverse_proxy = false; + let mut app_data_dir = default_app_data_dir(); + let mut web_root = None; + + let mut it = args.into_iter(); + while let Some(arg) = it.next() { + match arg.as_str() { + "--listen" => { + let value = it + .next() + .ok_or_else(|| "--listen requires an address".to_owned())?; + listen = value + .parse() + .map_err(|_| format!("invalid --listen address: {value}"))?; + } + "--public-origin" => { + let value = it + .next() + .ok_or_else(|| "--public-origin requires an origin".to_owned())?; + public_origin = Some(value); + } + "--allow-remote" => allow_remote = true, + "--trust-reverse-proxy" => trust_reverse_proxy = true, + "--app-data-dir" => { + let value = it + .next() + .ok_or_else(|| "--app-data-dir requires a path".to_owned())?; + app_data_dir = PathBuf::from(value); + } + "--web-root" => { + let value = it + .next() + .ok_or_else(|| "--web-root requires a path".to_owned())?; + web_root = Some(PathBuf::from(value)); + } + "--help" | "-h" => return Err(Self::usage()), + other => return Err(format!("unknown --serve argument: {other}")), + } + } + let web_root = resolve_web_root(web_root)?; + + Ok(Self { + listen, + public_origin, + allow_remote, + trust_reverse_proxy, + app_data_dir, + web_root, + }) + } + + /// Validates security-sensitive configuration. + pub fn validate(&self) -> Result<(), String> { + if let Some(origin) = &self.public_origin { + validate_origin(origin)?; + } + + if !self.listen.ip().is_loopback() && !self.allow_remote { + return Err( + "refusing non-loopback bind without --allow-remote and HTTPS proxy config" + .to_owned(), + ); + } + + if self.allow_remote { + let Some(origin) = &self.public_origin else { + return Err("--allow-remote requires --public-origin https://...".to_owned()); + }; + if !origin.starts_with("https://") { + return Err("--allow-remote requires an HTTPS public origin".to_owned()); + } + if !self.trust_reverse_proxy { + return Err("--allow-remote requires --trust-reverse-proxy".to_owned()); + } + } + + Ok(()) + } + + fn secure_cookie(&self) -> bool { + self.allow_remote + || self + .public_origin + .as_deref() + .is_some_and(|o| o.starts_with("https://")) + } + + /// Human-readable CLI usage. + #[must_use] + pub fn usage() -> String { + "usage: idea-serve [--listen IP:PORT] [--app-data-dir PATH] [--web-root PATH] [--allow-remote --public-origin https://host --trust-reverse-proxy]".to_owned() + } +} + +/// Current lifecycle state of an embedded web server. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EmbeddedServerState { + /// The accept loop has been spawned. + Running, + /// A shutdown signal was sent. + Stopping, +} + +/// Handle returned by [`run_embedded`]. +pub struct EmbeddedServerHandle { + url: String, + pairing_code: String, + shutdown: Option>, + task: JoinHandle>, + state: EmbeddedServerState, +} + +impl EmbeddedServerHandle { + /// Effective base URL after binding. Includes the OS-assigned port when + /// `listen` used port `0`. + #[must_use] + pub fn url(&self) -> &str { + &self.url + } + + /// Pairing code accepted by `POST /api/pair`. + #[must_use] + pub fn pairing_code(&self) -> &str { + &self.pairing_code + } + + /// Current lifecycle state. + #[must_use] + pub fn state(&self) -> EmbeddedServerState { + self.state + } + + /// Requests shutdown and waits for the accept loop to finish. + pub async fn stop(mut self) -> Result<(), String> { + self.state = EmbeddedServerState::Stopping; + if let Some(shutdown) = self.shutdown.take() { + let _ = shutdown.send(()); + } + self.task + .await + .map_err(|err| format!("embedded web server task failed: {err}"))? + } +} + +/// Starts the web server as an embedded library component. +/// +/// This is the integration point for the desktop-hosted server planned after +/// the headless extraction. The returned handle owns shutdown. +pub async fn run_embedded(config: ServerConfig) -> Result { + config.validate()?; + let listener = TcpListener::bind(config.listen) + .await + .map_err(|err| format!("failed to bind {}: {err}", config.listen))?; + let local_addr = listener + .local_addr() + .map_err(|err| format!("failed to read listener address: {err}"))?; + let state = Arc::new(ServerState::new(config.clone())); + let pairing_code = state.pairing_code().to_owned(); + let (shutdown_tx, shutdown_rx) = oneshot::channel(); + let task = tokio::spawn(run_listener(listener, state, shutdown_rx)); + Ok(EmbeddedServerHandle { + url: format!("http://{local_addr}"), + pairing_code, + shutdown: Some(shutdown_tx), + task, + state: EmbeddedServerState::Running, + }) +} + +fn resolve_web_root(explicit: Option) -> Result { + if let Some(path) = explicit { + return validate_web_root(path, "--web-root"); + } + if let Some(path) = env::var_os("IDEA_WEB_ROOT").map(PathBuf::from) { + return validate_web_root(path, "IDEA_WEB_ROOT"); + } + + let mut candidates = Vec::new(); + if let Ok(exe) = env::current_exe() { + if let Some(dir) = exe.parent() { + candidates.push(dir.join("web")); + candidates.push(dir.join("frontend").join("dist")); + } + } + if let Ok(cwd) = env::current_dir() { + candidates.push(cwd.join("frontend").join("dist")); + } + + for candidate in candidates { + if candidate.join("index.html").is_file() { + return Ok(candidate); + } + } + + Err("web assets not found: build frontend/dist or pass --web-root PATH".to_owned()) +} + +fn validate_web_root(path: PathBuf, source: &str) -> Result { + if path.join("index.html").is_file() { + Ok(path) + } else { + Err(format!( + "{source} does not contain an index.html: {}", + path.display() + )) + } +} + +struct ServerState { + config: ServerConfig, + app: BackendCore, + pairing_code: String, + sessions: Mutex>, + ws_pty_bridge: Arc>, + security_logger: Arc, +} + +impl ServerState { + fn new(config: ServerConfig) -> Self { + Self { + app: BackendCore::build(config.app_data_dir.clone()), + config, + pairing_code: new_pairing_code(), + sessions: Mutex::new(HashSet::new()), + ws_pty_bridge: Arc::new(OutputBridge::new()), + security_logger: Arc::new(StderrSecurityLogger), + } + } + + #[cfg(test)] + fn new_for_test(config: ServerConfig, pairing_code: impl Into) -> Self { + Self::new_for_test_with_logger(config, pairing_code, Arc::new(NoopSecurityLogger)) + } + + #[cfg(test)] + fn new_for_test_with_logger( + config: ServerConfig, + pairing_code: impl Into, + security_logger: Arc, + ) -> Self { + Self { + app: BackendCore::build(config.app_data_dir.clone()), + config, + pairing_code: pairing_code.into(), + sessions: Mutex::new(HashSet::new()), + ws_pty_bridge: Arc::new(OutputBridge::new()), + security_logger, + } + } + + fn pairing_code(&self) -> &str { + &self.pairing_code + } + + fn create_session(&self) -> String { + let token = new_session_token(); + if let Ok(mut sessions) = self.sessions.lock() { + sessions.insert(token.clone()); + } + token + } + + fn has_session(&self, token: &str) -> bool { + self.sessions + .lock() + .map(|sessions| sessions.contains(token)) + .unwrap_or(false) + } + + fn revoke_session(&self, token: &str) -> bool { + self.sessions + .lock() + .map(|mut sessions| sessions.remove(token)) + .unwrap_or(false) + } + + fn log_security(&self, event: SecurityLogEvent) { + self.security_logger.log(event); + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum SecurityLogEvent { + PairingSucceeded { + origin: Option, + }, + PairingFailed { + origin: Option, + reason: &'static str, + }, + OriginRejected { + origin: Option, + route: String, + }, + WsUpgradeRejected { + origin: Option, + reason: &'static str, + }, + SessionRevoked { + origin: Option, + }, +} + +trait SecurityLogger: Send + Sync { + fn log(&self, event: SecurityLogEvent); +} + +struct StderrSecurityLogger; + +impl SecurityLogger for StderrSecurityLogger { + fn log(&self, event: SecurityLogEvent) { + eprintln!("idea --serve security: {}", security_log_line(&event)); + } +} + +#[cfg(test)] +struct NoopSecurityLogger; + +#[cfg(test)] +impl SecurityLogger for NoopSecurityLogger { + fn log(&self, _event: SecurityLogEvent) {} +} + +fn security_log_line(event: &SecurityLogEvent) -> String { + match event { + SecurityLogEvent::PairingSucceeded { origin } => { + format!("pairing succeeded origin={}", origin_label(origin)) + } + SecurityLogEvent::PairingFailed { origin, reason } => { + format!( + "pairing failed reason={reason} origin={}", + origin_label(origin) + ) + } + SecurityLogEvent::OriginRejected { origin, route } => { + format!( + "origin rejected route={} origin={}", + route, + origin_label(origin) + ) + } + SecurityLogEvent::WsUpgradeRejected { origin, reason } => { + format!( + "websocket upgrade rejected reason={reason} origin={}", + origin_label(origin) + ) + } + SecurityLogEvent::SessionRevoked { origin } => { + format!("session revoked origin={}", origin_label(origin)) + } + } +} + +fn origin_label(origin: &Option) -> &str { + origin.as_deref().unwrap_or("") +} + +async fn run_server(config: ServerConfig, state: Arc) -> Result<(), String> { + let listener = TcpListener::bind(config.listen) + .await + .map_err(|err| format!("failed to bind {}: {err}", config.listen))?; + let (_shutdown_tx, shutdown_rx) = oneshot::channel(); + run_listener(listener, state, shutdown_rx).await +} + +async fn run_listener( + listener: TcpListener, + state: Arc, + mut shutdown: oneshot::Receiver<()>, +) -> Result<(), String> { + loop { + let accepted = tokio::select! { + result = listener.accept() => result, + _ = &mut shutdown => return Ok(()), + }; + let (stream, _) = accepted.map_err(|err| format!("failed to accept connection: {err}"))?; + let state = Arc::clone(&state); + tokio::spawn(async move { + if let Err(err) = handle_tcp_connection(stream, state).await { + eprintln!("idea --serve: connection error: {err}"); + } + }); + } +} + +async fn handle_tcp_connection( + mut stream: tokio::net::TcpStream, + state: Arc, +) -> Result<(), String> { + let mut buffer = Vec::with_capacity(8192); + let mut chunk = [0_u8; 2048]; + let header_end = loop { + let read = stream + .read(&mut chunk) + .await + .map_err(|err| format!("failed to read request: {err}"))?; + if read == 0 { + return Ok(()); + } + buffer.extend_from_slice(&chunk[..read]); + if buffer.len() > 1024 * 1024 { + return Err("request too large".to_owned()); + } + if let Some(pos) = find_header_end(&buffer) { + break pos; + } + }; + + let (method, uri, headers, content_length) = parse_http_request_head(&buffer[..header_end])?; + if method == Method::GET && uri.path() == WS_PATH { + return handle_ws_upgrade(stream, headers, state).await; + } + + let body_start = header_end + 4; + while buffer.len() < body_start + content_length { + let read = stream + .read(&mut chunk) + .await + .map_err(|err| format!("failed to read request body: {err}"))?; + if read == 0 { + break; + } + buffer.extend_from_slice(&chunk[..read]); + if buffer.len() > 1024 * 1024 { + return Err("request too large".to_owned()); + } + } + if buffer.len() < body_start + content_length { + return Err("truncated request body".to_owned()); + } + + let response = dispatch_http( + method, + uri, + headers, + Bytes::copy_from_slice(&buffer[body_start..body_start + content_length]), + state, + ) + .await; + write_http_response(&mut stream, response).await +} + +#[cfg(test)] +async fn handle_request( + req: Request, + state: Arc, +) -> Response { + let (parts, body) = req.into_parts(); + let body = match body.collect().await { + Ok(body) => body.to_bytes(), + Err(err) => { + return error_response( + StatusCode::BAD_REQUEST, + "INVALID", + format!("invalid request body: {err}"), + None, + ); + } + }; + + dispatch_http( + parts.method, + parts.uri, + parts.headers, + body, + Arc::clone(&state), + ) + .await +} + +fn find_header_end(buffer: &[u8]) -> Option { + buffer.windows(4).position(|window| window == b"\r\n\r\n") +} + +fn parse_http_request_head(head: &[u8]) -> Result<(Method, Uri, HeaderMap, usize), String> { + let text = std::str::from_utf8(head).map_err(|_| "request head is not UTF-8".to_owned())?; + let mut lines = text.split("\r\n"); + let request_line = lines + .next() + .ok_or_else(|| "missing request line".to_owned())?; + let mut request_parts = request_line.split_whitespace(); + let method = request_parts + .next() + .ok_or_else(|| "missing method".to_owned())? + .parse::() + .map_err(|_| "invalid method".to_owned())?; + let uri = request_parts + .next() + .ok_or_else(|| "missing uri".to_owned())? + .parse::() + .map_err(|_| "invalid uri".to_owned())?; + let mut headers = HeaderMap::new(); + let mut content_length = 0; + + for line in lines { + if line.is_empty() { + continue; + } + let Some((name, value)) = line.split_once(':') else { + return Err("invalid header line".to_owned()); + }; + let name = http::header::HeaderName::from_bytes(name.trim().as_bytes()) + .map_err(|_| "invalid header name".to_owned())?; + let value = + HeaderValue::from_str(value.trim()).map_err(|_| "invalid header value".to_owned())?; + if name == http::header::CONTENT_LENGTH { + content_length = value + .to_str() + .ok() + .and_then(|raw| raw.parse::().ok()) + .ok_or_else(|| "invalid content-length".to_owned())?; + } + headers.insert(name, value); + } + + Ok((method, uri, headers, content_length)) +} + +async fn write_http_response( + stream: &mut tokio::net::TcpStream, + response: Response, +) -> Result<(), String> { + let status = response.status(); + let headers = response.headers().clone(); + let body = response + .into_body() + .collect() + .await + .map_err(|err| format!("failed to collect response: {err}"))? + .to_bytes(); + let reason = status.canonical_reason().unwrap_or("Unknown"); + let mut bytes = format!("HTTP/1.1 {} {}\r\n", status.as_u16(), reason).into_bytes(); + for (name, value) in &headers { + bytes.extend_from_slice(name.as_str().as_bytes()); + bytes.extend_from_slice(b": "); + bytes.extend_from_slice(value.as_bytes()); + bytes.extend_from_slice(b"\r\n"); + } + bytes.extend_from_slice(format!("content-length: {}\r\n", body.len()).as_bytes()); + bytes.extend_from_slice(b"connection: close\r\n\r\n"); + bytes.extend_from_slice(&body); + stream + .write_all(&bytes) + .await + .map_err(|err| format!("failed to write response: {err}")) +} + +async fn handle_ws_upgrade( + mut stream: tokio::net::TcpStream, + headers: HeaderMap, + state: Arc, +) -> Result<(), String> { + let accept = match validate_ws_upgrade(&headers, &state) { + Ok(accept) => accept, + Err(response) => return write_http_response(&mut stream, *response).await, + }; + + let response = format!( + "HTTP/1.1 101 Switching Protocols\r\n\ + upgrade: websocket\r\n\ + connection: Upgrade\r\n\ + sec-websocket-accept: {accept}\r\n\r\n" + ); + stream + .write_all(response.as_bytes()) + .await + .map_err(|err| format!("failed to write websocket upgrade: {err}"))?; + run_ws_connection(stream, state).await +} + +fn validate_ws_upgrade( + headers: &HeaderMap, + state: &ServerState, +) -> Result>> { + let origin = match validate_request_origin(headers, &state.config) { + Ok(origin) => origin, + Err(response) => { + state.log_security(SecurityLogEvent::OriginRejected { + origin: request_origin(headers), + route: WS_PATH.to_owned(), + }); + return Err(response); + } + }; + let Some(token) = session_cookie(headers) else { + state.log_security(SecurityLogEvent::WsUpgradeRejected { + origin: origin.clone(), + reason: "missing_session", + }); + return Err(Box::new(error_response( + StatusCode::UNAUTHORIZED, + "UNAUTHORIZED", + "missing session cookie", + origin.as_deref(), + ))); + }; + if !state.has_session(&token) { + state.log_security(SecurityLogEvent::WsUpgradeRejected { + origin: origin.clone(), + reason: "invalid_session", + }); + return Err(Box::new(error_response( + StatusCode::UNAUTHORIZED, + "UNAUTHORIZED", + "invalid session cookie", + origin.as_deref(), + ))); + } + if !header_contains_token(headers, "upgrade", "websocket") + || !header_contains_token(headers, "connection", "upgrade") + { + state.log_security(SecurityLogEvent::WsUpgradeRejected { + origin: origin.clone(), + reason: "invalid_upgrade", + }); + return Err(Box::new(error_response( + StatusCode::BAD_REQUEST, + "INVALID", + "missing websocket upgrade headers", + origin.as_deref(), + ))); + } + let Some(key) = headers + .get("sec-websocket-key") + .and_then(|value| value.to_str().ok()) + else { + state.log_security(SecurityLogEvent::WsUpgradeRejected { + origin: origin.clone(), + reason: "missing_key", + }); + return Err(Box::new(error_response( + StatusCode::BAD_REQUEST, + "INVALID", + "missing Sec-WebSocket-Key", + origin.as_deref(), + ))); + }; + Ok(websocket_accept(key)) +} + +fn header_contains_token(headers: &HeaderMap, name: &str, needle: &str) -> bool { + headers + .get(name) + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| { + value + .split(',') + .any(|token| token.trim().eq_ignore_ascii_case(needle)) + }) +} + +async fn run_ws_connection( + stream: tokio::net::TcpStream, + state: Arc, +) -> Result<(), String> { + let (mut reader, mut writer) = stream.into_split(); + let (tx, mut rx) = mpsc::channel::(WS_OUTPUT_BUFFER); + let owned = Arc::new(Mutex::new(Vec::<(SessionId, u64)>::new())); + let event_relay_task = + spawn_ws_domain_event_relay(state.app.event_bus.raw_receiver(), tx.clone()); + let writer_task = tokio::spawn(async move { + while let Some(frame) = rx.recv().await { + let text = serde_json::to_vec(&frame) + .map_err(|err| format!("failed to encode server frame: {err}"))?; + let bytes = encode_ws_frame(WsOpcode::Text, &text); + writer + .write_all(&bytes) + .await + .map_err(|err| format!("failed to write websocket frame: {err}"))?; + } + Ok::<(), String>(()) + }); + + loop { + let frame = match read_ws_frame(&mut reader).await { + Ok(frame) => frame, + Err(err) => { + let _ = tx + .send(ServerFrame::error( + None, + None, + "WS_PROTOCOL", + err.to_string(), + )) + .await; + break; + } + }; + match frame.opcode { + WsOpcode::Text => { + let parsed = serde_json::from_slice::(&frame.payload) + .map_err(|err| format!("invalid client frame JSON: {err}")); + match parsed { + Ok(frame) => { + handle_client_frame(frame, &state, &tx, &owned).await; + } + Err(err) => { + let _ = tx + .send(ServerFrame::error(None, None, "INVALID", err)) + .await; + } + } + } + WsOpcode::Ping => { + let _ = tx.send(ServerFrame::pong()).await; + } + WsOpcode::Close => break, + WsOpcode::Pong => {} + WsOpcode::Binary => { + let _ = tx + .send(ServerFrame::error( + None, + None, + "UNSUPPORTED", + "binary websocket frames are not supported", + )) + .await; + } + } + } + + if let Ok(owned) = owned.lock() { + for (session, gen) in owned.iter() { + state.ws_pty_bridge.unregister_if(session, *gen); + } + } + event_relay_task.abort(); + drop(tx); + writer_task + .await + .map_err(|err| format!("websocket writer task failed: {err}"))? +} + +fn spawn_ws_domain_event_relay( + mut rx: tokio::sync::broadcast::Receiver, + tx: mpsc::Sender, +) -> tokio::task::JoinHandle<()> { + use tokio::sync::broadcast::error::RecvError; + + tokio::spawn(async move { + loop { + match rx.recv().await { + Ok(event) => { + if matches!(event, DomainEvent::PtyOutput { .. }) { + continue; + } + match tx.try_send(ServerFrame::domain_event(&event)) { + Ok(()) | Err(mpsc::error::TrySendError::Full(_)) => {} + Err(mpsc::error::TrySendError::Closed(_)) => break, + } + } + Err(RecvError::Lagged(_)) => continue, + Err(RecvError::Closed) => break, + } + } + }) +} + +async fn handle_client_frame( + frame: ClientFrame, + state: &Arc, + tx: &mpsc::Sender, + owned: &Arc>>, +) { + let result = match frame.kind.as_str() { + "terminal.open" | "open_terminal" => ws_open_terminal(&frame, state, tx, owned).await, + "agent.launch" | "launch_agent" => ws_launch_agent(&frame, state, tx, owned).await, + "terminal.attach" | "attach_terminal" => ws_attach_terminal(&frame, state, tx, owned).await, + "terminal.input" | "input" => ws_input(&frame, state), + "terminal.resize" | "resize" => ws_resize(&frame, state), + "terminal.detach" | "detach" => ws_detach(&frame, state, owned), + "terminal.close" | "close" => match ws_close(&frame, state, owned).await { + Ok((session_id, exit_code)) => { + let _ = tx + .send(ServerFrame::status(session_id, "exited", exit_code)) + .await; + Ok(()) + } + Err(err) => Err(err), + }, + "ping" => { + let _ = tx.send(ServerFrame::pong()).await; + Ok(()) + } + _ => Err(ErrorDto { + code: "UNKNOWN_FRAME".to_owned(), + message: format!("unknown websocket frame kind: {}", frame.kind), + }), + }; + + if let Err(err) = result { + let _ = tx + .send(ServerFrame::error( + Some(frame.id), + payload_session_id(&frame.payload), + err.code, + err.message, + )) + .await; + } +} + +async fn ws_open_terminal( + frame: &ClientFrame, + state: &Arc, + tx: &mpsc::Sender, + owned: &Arc>>, +) -> Result<(), ErrorDto> { + let request_value = frame + .payload + .get("request") + .cloned() + .unwrap_or_else(|| frame.payload.clone()); + let request: OpenTerminalRequestDto = + serde_json::from_value(request_value).map_err(invalid_args_error)?; + let output = state + .app + .open_terminal + .execute(request.into()) + .await + .map_err(ErrorDto::from)?; + let dto = TerminalSessionDto::from(output); + let sid = parse_session_id(&dto.session_id)?; + let sink = WsPtySink::new(sid, tx.clone(), 0); + tx.send(ServerFrame::attached(&frame.id, &dto, Vec::new(), 0, false)) + .await + .map_err(|_| ErrorDto { + code: "WS_CLOSED".to_owned(), + message: "websocket output closed".to_owned(), + })?; + attach_sink_and_pump(state, sid, sink, owned) +} + +async fn ws_launch_agent( + frame: &ClientFrame, + state: &Arc, + tx: &mpsc::Sender, + owned: &Arc>>, +) -> Result<(), ErrorDto> { + let request_value = frame + .payload + .get("request") + .cloned() + .unwrap_or_else(|| frame.payload.clone()); + let request: LaunchAgentRequestDto = + serde_json::from_value(request_value).map_err(invalid_args_error)?; + let output = execute_launch_agent_for_ws(state, request).await?; + send_launch_agent_attached(frame, state, tx, owned, output).await +} + +async fn send_launch_agent_attached( + frame: &ClientFrame, + state: &Arc, + tx: &mpsc::Sender, + owned: &Arc>>, + output: application::LaunchAgentOutput, +) -> Result<(), ErrorDto> { + if output.structured.is_some() { + return Err(ErrorDto { + code: "UNSUPPORTED".to_owned(), + message: "structured agent sessions do not stream over the PTY websocket".to_owned(), + }); + } + let session_id = output.session.id; + let dto = TerminalSessionDto::from(output); + let sid = parse_session_id(&dto.session_id)?; + let sink = WsPtySink::new(sid, tx.clone(), 0); + tx.send(ServerFrame::attached(&frame.id, &dto, Vec::new(), 0, false)) + .await + .map_err(|_| ErrorDto { + code: "WS_CLOSED".to_owned(), + message: "websocket output closed".to_owned(), + })?; + attach_sink_and_pump(state, session_id, sink, owned) +} + +async fn execute_launch_agent_for_ws( + state: &Arc, + request: LaunchAgentRequestDto, +) -> Result { + let project = resolve_project_readonly(&request.project_id, &state.app).await?; + let agent_id = parse_agent_id(&request.agent_id)?; + let node_id = request.node_id.as_deref().map(parse_node_id).transpose()?; + let mcp_runtime = backend::mcp_endpoint::idea_exe_path().map(|exe| McpRuntime { + exe, + endpoint: backend::mcp_endpoint::mcp_endpoint(&project.id) + .as_cli_arg() + .to_owned(), + project_id: project.id.as_uuid().simple().to_string(), + requester: agent_id.to_string(), + }); + + state.app.reconcile_claude_run_dirs(&project).await; + + let resume_project = project.clone(); + let rotation_root = project.root.clone(); + let watch_root = project.root.clone(); + let output = state + .app + .launch_agent + .execute(LaunchAgentInput { + project, + agent_id, + rows: request.rows, + cols: request.cols, + node_id, + conversation_id: request.conversation_id.clone(), + mcp_runtime, + allow_structured_alongside_pty: false, + }) + .await + .map_err(ErrorDto::from)?; + + if let Ok(mut contexts) = state.app.resume_contexts.lock() { + contexts.insert( + agent_id, + backend::ResumeContext { + project: resume_project, + rows: request.rows, + cols: request.cols, + }, + ); + } + + if let Some(profile) = output.profile.as_ref() { + state.app.arm_turn_watch( + &watch_root, + agent_id, + profile, + output.assigned_conversation_id.clone(), + ); + } + + if let Some(conversation) = request + .conversation_id + .as_deref() + .and_then(|raw| uuid::Uuid::parse_str(raw).ok()) + .map(domain::ConversationId::from_uuid) + { + let rotate = Arc::clone(&state.app.rotate_conversation_log); + tokio::spawn(async move { + let _ = rotate + .execute(RotateConversationLogInput { + project_root: rotation_root, + conversation, + }) + .await; + }); + } + + Ok(output) +} + +async fn ws_attach_terminal( + frame: &ClientFrame, + state: &Arc, + tx: &mpsc::Sender, + owned: &Arc>>, +) -> Result<(), ErrorDto> { + let session_id = payload_string(&frame.payload, "sessionId")?; + let sid = parse_session_id(&session_id)?; + let handle = PtyHandle { session_id: sid }; + let scrollback = state + .app + .pty_port + .scrollback(&handle) + .map_err(|err| ErrorDto::from(application::AppError::from(err)))?; + let rows = payload_u16(&frame.payload, "rows").unwrap_or(24); + let cols = payload_u16(&frame.payload, "cols").unwrap_or(80); + let dto = TerminalSessionDto { + session_id, + cwd: String::new(), + rows, + cols, + assigned_conversation_id: None, + engine_session_id: None, + cell_kind: backend::dto::CellKind::Pty, + }; + let next_seq = u64::from(!scrollback.is_empty()); + tx.send(ServerFrame::attached( + &frame.id, + &dto, + scrollback.clone(), + next_seq, + frame.payload.get("lastSeq").is_some(), + )) + .await + .map_err(|_| ErrorDto { + code: "WS_CLOSED".to_owned(), + message: "websocket output closed".to_owned(), + })?; + let sink = WsPtySink::new(sid, tx.clone(), next_seq); + attach_sink_and_pump(state, sid, sink, owned) +} + +fn attach_sink_and_pump( + state: &Arc, + sid: SessionId, + sink: WsPtySink, + owned: &Arc>>, +) -> Result<(), ErrorDto> { + let gen = state.ws_pty_bridge.register(sid, Arc::new(sink)); + if let Ok(mut owned) = owned.lock() { + owned.push((sid, gen)); + } + let handle = PtyHandle { session_id: sid }; + let stream = state + .app + .pty_port + .subscribe_output(&handle) + .map_err(|err| ErrorDto::from(application::AppError::from(err)))?; + let bridge = Arc::clone(&state.ws_pty_bridge); + std::thread::spawn(move || { + for chunk in stream { + if !bridge.send_output(&sid, chunk) { + break; + } + } + bridge.unregister_if(&sid, gen); + }); + Ok(()) +} + +fn ws_input(frame: &ClientFrame, state: &Arc) -> Result<(), ErrorDto> { + let sid = parse_session_id(&payload_string(&frame.payload, "sessionId")?)?; + let bytes = payload_bytes(&frame.payload)?; + state + .app + .write_terminal + .execute(WriteToTerminalInput { + session_id: sid, + data: bytes, + }) + .map_err(ErrorDto::from) +} + +fn ws_resize(frame: &ClientFrame, state: &Arc) -> Result<(), ErrorDto> { + let sid = parse_session_id(&payload_string(&frame.payload, "sessionId")?)?; + let rows = payload_u16(&frame.payload, "rows").ok_or_else(|| ErrorDto { + code: "INVALID".to_owned(), + message: "resize requires rows".to_owned(), + })?; + let cols = payload_u16(&frame.payload, "cols").ok_or_else(|| ErrorDto { + code: "INVALID".to_owned(), + message: "resize requires cols".to_owned(), + })?; + state + .app + .resize_terminal + .execute(ResizeTerminalInput { + session_id: sid, + rows, + cols, + }) + .map_err(ErrorDto::from) +} + +fn ws_detach( + frame: &ClientFrame, + state: &Arc, + owned: &Arc>>, +) -> Result<(), ErrorDto> { + let sid = parse_session_id(&payload_string(&frame.payload, "sessionId")?)?; + if let Ok(mut owned) = owned.lock() { + if let Some(index) = owned.iter().rposition(|(session, _)| *session == sid) { + let (_, gen) = owned.remove(index); + state.ws_pty_bridge.unregister_if(&sid, gen); + } + } + Ok(()) +} + +async fn ws_close( + frame: &ClientFrame, + state: &Arc, + owned: &Arc>>, +) -> Result<(SessionId, Option), ErrorDto> { + let sid = parse_session_id(&payload_string(&frame.payload, "sessionId")?)?; + ws_detach(frame, state, owned)?; + let output = state + .app + .close_terminal + .execute(CloseTerminalInput { session_id: sid }) + .await + .map_err(ErrorDto::from)?; + Ok((sid, output.code)) +} + +async fn dispatch_http( + method: Method, + uri: Uri, + headers: HeaderMap, + body: Bytes, + state: Arc, +) -> Response { + if has_forbidden_query_secret(&uri) { + return error_response( + StatusCode::BAD_REQUEST, + "INVALID", + "secrets in URL query strings are forbidden", + None, + ); + } + + if !is_api_path(uri.path()) { + return serve_static(&state.config.web_root, method, uri.path()).await; + } + + let origin = match validate_request_origin(&headers, &state.config) { + Ok(origin) => origin, + Err(response) => { + state.log_security(SecurityLogEvent::OriginRejected { + origin: request_origin(&headers), + route: uri.path().to_owned(), + }); + return *response; + } + }; + + if method == Method::OPTIONS { + return cors_response(StatusCode::NO_CONTENT, origin.as_deref()); + } + + match (method, uri.path()) { + (Method::POST, "/api/pair") => pair(body, state, origin.as_deref()).await, + (Method::POST, "/api/logout") => { + let Some(token) = session_cookie(&headers) else { + return error_response( + StatusCode::UNAUTHORIZED, + "UNAUTHORIZED", + "missing session cookie", + origin.as_deref(), + ); + }; + if !state.revoke_session(&token) { + return error_response( + StatusCode::UNAUTHORIZED, + "UNAUTHORIZED", + "invalid session cookie", + origin.as_deref(), + ); + } + state.log_security(SecurityLogEvent::SessionRevoked { + origin: origin.clone(), + }); + logout_response(&state, origin.as_deref()) + } + (Method::POST, "/api/invoke") => { + let Some(token) = session_cookie(&headers) else { + return error_response( + StatusCode::UNAUTHORIZED, + "UNAUTHORIZED", + "missing session cookie", + origin.as_deref(), + ); + }; + if !state.has_session(&token) { + return error_response( + StatusCode::UNAUTHORIZED, + "UNAUTHORIZED", + "invalid session cookie", + origin.as_deref(), + ); + } + invoke(body, state, origin.as_deref()).await + } + (_, "/api/pair" | "/api/invoke" | "/api/logout") => error_response( + StatusCode::METHOD_NOT_ALLOWED, + "METHOD_NOT_ALLOWED", + "method not allowed", + origin.as_deref(), + ), + _ if is_api_path(uri.path()) => error_response( + StatusCode::NOT_FOUND, + "NOT_FOUND", + "route not found", + origin.as_deref(), + ), + _ => error_response(StatusCode::NOT_FOUND, "NOT_FOUND", "route not found", None), + } +} + +fn is_api_path(path: &str) -> bool { + path == "/api" || path.starts_with("/api/") +} + +async fn serve_static(web_root: &Path, method: Method, path: &str) -> Response { + if !matches!(method, Method::GET | Method::HEAD) { + return error_response( + StatusCode::METHOD_NOT_ALLOWED, + "METHOD_NOT_ALLOWED", + "method not allowed", + None, + ); + } + + let Some(route) = static_route(web_root, path) else { + return error_response(StatusCode::NOT_FOUND, "NOT_FOUND", "route not found", None); + }; + + let file = if route.candidate.is_file() { + route.candidate + } else if route.spa_fallback { + web_root.join("index.html") + } else { + return error_response(StatusCode::NOT_FOUND, "NOT_FOUND", "route not found", None); + }; + + let bytes = match tokio::fs::read(&file).await { + Ok(bytes) => bytes, + Err(_) => { + return error_response(StatusCode::NOT_FOUND, "NOT_FOUND", "route not found", None); + } + }; + static_file_response(&file, bytes, method == Method::HEAD) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct StaticRoute { + candidate: PathBuf, + spa_fallback: bool, +} + +fn static_route(web_root: &Path, request_path: &str) -> Option { + if !request_path.starts_with('/') || request_path.contains('\\') { + return None; + } + let lower = request_path.to_ascii_lowercase(); + if lower.contains("%2e") || lower.contains("%2f") || lower.contains("%5c") { + return None; + } + + let mut candidate = web_root.to_path_buf(); + let mut last = ""; + for segment in request_path + .split('/') + .filter(|segment| !segment.is_empty()) + { + if segment == "." || segment == ".." || segment.starts_with('.') || segment.contains('%') { + return None; + } + candidate.push(segment); + last = segment; + } + + if request_path == "/" { + return Some(StaticRoute { + candidate: web_root.join("index.html"), + spa_fallback: false, + }); + } + + Some(StaticRoute { + candidate, + spa_fallback: !last.contains('.'), + }) +} + +fn static_file_response(path: &Path, bytes: Vec, head_only: bool) -> Response { + let is_index = path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name == "index.html"); + let body = if head_only { Vec::new() } else { bytes }; + let mut response = Response::builder() + .status(StatusCode::OK) + .header(CONTENT_TYPE, content_type(path)) + .header("x-content-type-options", "nosniff") + .body(Full::new(Bytes::from(body))) + .expect("static response is valid"); + if is_index { + response.headers_mut().insert( + "content-security-policy", + HeaderValue::from_static( + "default-src 'self'; connect-src 'self' ws: wss:; img-src 'self' data: blob:; style-src 'self' 'unsafe-inline'; script-src 'self'; base-uri 'self'; frame-ancestors 'none'", + ), + ); + } + response +} + +fn content_type(path: &Path) -> &'static str { + match path.extension().and_then(|ext| ext.to_str()).unwrap_or("") { + "html" => "text/html; charset=utf-8", + "js" | "mjs" => "text/javascript; charset=utf-8", + "css" => "text/css; charset=utf-8", + "json" => "application/json; charset=utf-8", + "svg" => "image/svg+xml", + "png" => "image/png", + "jpg" | "jpeg" => "image/jpeg", + "gif" => "image/gif", + "webp" => "image/webp", + "ico" => "image/x-icon", + "wasm" => "application/wasm", + "txt" => "text/plain; charset=utf-8", + _ => "application/octet-stream", + } +} + +#[derive(Deserialize)] +struct PairRequest { + code: String, +} + +async fn pair( + body: Bytes, + state: Arc, + origin: Option<&str>, +) -> Response { + let request = match serde_json::from_slice::(&body) { + Ok(request) => request, + Err(err) => { + return error_response( + StatusCode::BAD_REQUEST, + "INVALID", + format!("invalid pairing request: {err}"), + origin, + ); + } + }; + + if request.code != state.pairing_code() { + state.log_security(SecurityLogEvent::PairingFailed { + origin: origin.map(str::to_owned), + reason: "wrong_code", + }); + return error_response( + StatusCode::FORBIDDEN, + "FORBIDDEN", + "invalid pairing code", + origin, + ); + } + + let token = state.create_session(); + state.log_security(SecurityLogEvent::PairingSucceeded { + origin: origin.map(str::to_owned), + }); + let cookie = Cookie::build((SESSION_COOKIE, token)) + .path("/") + .http_only(true) + .secure(state.config.secure_cookie()) + .same_site(SameSite::Strict) + .build(); + let mut response = json_response(StatusCode::OK, &json!({ "paired": true }), origin); + response.headers_mut().insert( + SET_COOKIE, + HeaderValue::from_str(&cookie.to_string()).expect("session cookie is header-safe"), + ); + response +} + +fn logout_response(state: &ServerState, origin: Option<&str>) -> Response { + let mut response = json_response(StatusCode::OK, &json!({ "revoked": true }), origin); + let secure = if state.config.secure_cookie() { + "; Secure" + } else { + "" + }; + let cookie = format!("{SESSION_COOKIE}=; Path=/; HttpOnly{secure}; SameSite=Strict; Max-Age=0"); + response.headers_mut().insert( + SET_COOKIE, + HeaderValue::from_str(&cookie).expect("session clearing cookie is header-safe"), + ); + response +} + +#[derive(Deserialize)] +struct InvokeRequest { + command: String, + #[serde(default)] + args: Value, +} + +async fn invoke( + body: Bytes, + state: Arc, + origin: Option<&str>, +) -> Response { + let request = match serde_json::from_slice::(&body) { + Ok(request) => request, + Err(err) => { + return error_response( + StatusCode::BAD_REQUEST, + "INVALID", + format!("invalid invoke request: {err}"), + origin, + ); + } + }; + + let result = match request.command.as_str() { + "health" => invoke_health(&request.args, &state.app), + "list_projects" => invoke_list_projects(&state.app).await, + "open_project" => invoke_open_project(&request.args, &state.app).await, + "get_project_work_state" => invoke_get_project_work_state(&request.args, &state.app).await, + "list_background_tasks" => invoke_list_background_tasks(&request.args, &state.app).await, + "cancel_background_task" => invoke_cancel_background_task(&request.args, &state.app).await, + "retry_background_task" => invoke_retry_background_task(&request.args, &state.app).await, + _ => Err(ErrorDto { + code: "UNKNOWN_COMMAND".to_owned(), + message: format!("unknown command: {}", request.command), + }), + }; + + match result { + Ok(value) => json_response(StatusCode::OK, &value, origin), + Err(error) => error_dto_response(status_for_error(&error), error, origin), + } +} + +fn invoke_health(args: &Value, state: &BackendCore) -> Result { + let request = optional_request::(args)?; + let output = state + .health + .execute(request.unwrap_or_default().into()) + .map(HealthResponseDto::from) + .map_err(ErrorDto::from)?; + serde_json::to_value(output).map_err(serialization_error) +} + +async fn invoke_list_projects(state: &BackendCore) -> Result { + let output = state + .list_projects + .execute() + .await + .map(ProjectListDto::from) + .map_err(ErrorDto::from)?; + serde_json::to_value(output).map_err(serialization_error) +} + +async fn invoke_open_project(args: &Value, state: &BackendCore) -> Result { + let project_id = args + .get("projectId") + .and_then(Value::as_str) + .ok_or_else(|| ErrorDto { + code: "INVALID".to_owned(), + message: "open_project requires args.projectId".to_owned(), + })?; + let project = resolve_project_readonly(project_id, state).await?; + serde_json::to_value(ProjectDto::from(project)).map_err(serialization_error) +} + +async fn invoke_get_project_work_state( + args: &Value, + state: &BackendCore, +) -> Result { + let project_id = args + .get("projectId") + .and_then(Value::as_str) + .ok_or_else(|| ErrorDto { + code: "INVALID".to_owned(), + message: "get_project_work_state requires args.projectId".to_owned(), + })?; + let project = resolve_project_readonly(project_id, state).await?; + let output = state + .get_project_work_state + .execute(GetProjectWorkStateInput { project }) + .await + .map(ProjectWorkStateDto::from) + .map_err(ErrorDto::from)?; + serde_json::to_value(output).map_err(serialization_error) +} + +async fn invoke_list_background_tasks( + args: &Value, + state: &BackendCore, +) -> Result { + let project_id = args + .get("projectId") + .and_then(Value::as_str) + .ok_or_else(|| ErrorDto { + code: "INVALID".to_owned(), + message: "list_background_tasks requires args.projectId".to_owned(), + }) + .and_then(parse_project_id)?; + let agent_id = args + .get("agentId") + .and_then(Value::as_str) + .map(parse_agent_id) + .transpose()?; + + let mut by_id = std::collections::HashMap::::new(); + if let Some(agent_id) = agent_id { + for task in state + .background_task_store + .list_open_for_agent(agent_id) + .await + .map_err(background_error)? + { + by_id.insert(task.id, task); + } + } + for task in state + .background_task_store + .list_undelivered_completions() + .await + .map_err(background_error)? + { + by_id.entry(task.id).or_insert(task); + } + + let mut tasks = by_id + .into_values() + .filter(|task| task.project_id == project_id) + .filter(|task| agent_id.map_or(true, |agent_id| task.owner_agent_id == agent_id)) + .collect::>(); + tasks.sort_by_key(|task| (task.created_at_ms, task.id)); + let dto = tasks + .into_iter() + .map(BackgroundTaskDto::from) + .collect::>(); + serde_json::to_value(dto).map_err(serialization_error) +} + +async fn invoke_cancel_background_task( + args: &Value, + state: &BackendCore, +) -> Result { + let task_id = task_id_arg(args, "cancel_background_task")?; + verify_background_task_project(args, state, task_id).await?; + let output = state + .cancel_background_task + .execute(task_id) + .await + .map_err(ErrorDto::from)?; + serde_json::to_value(output.task.map(BackgroundTaskDto::from)).map_err(serialization_error) +} + +async fn invoke_retry_background_task( + args: &Value, + state: &BackendCore, +) -> Result { + let task_id = task_id_arg(args, "retry_background_task")?; + verify_background_task_project(args, state, task_id).await?; + let output = state + .retry_background_task + .execute(task_id) + .await + .map_err(ErrorDto::from)?; + serde_json::to_value(BackgroundTaskDto::from(output.task)).map_err(serialization_error) +} + +fn task_id_arg(args: &Value, command: &str) -> Result { + args.get("taskId") + .and_then(Value::as_str) + .ok_or_else(|| ErrorDto { + code: "INVALID".to_owned(), + message: format!("{command} requires args.taskId"), + }) + .and_then(parse_task_id) +} + +async fn verify_background_task_project( + args: &Value, + state: &BackendCore, + task_id: domain::TaskId, +) -> Result<(), ErrorDto> { + let Some(project_id) = args.get("projectId").and_then(Value::as_str) else { + return Ok(()); + }; + let project_id = parse_project_id(project_id)?; + let Some(task) = state + .background_task_store + .get(task_id) + .await + .map_err(background_error)? + else { + return Err(ErrorDto { + code: "NOT_FOUND".to_owned(), + message: format!("background task not found: {task_id}"), + }); + }; + if task.project_id != project_id { + return Err(ErrorDto { + code: "FORBIDDEN".to_owned(), + message: "background task does not belong to requested project".to_owned(), + }); + } + Ok(()) +} + +fn background_error(err: BackgroundTaskPortError) -> ErrorDto { + let code = match &err { + BackgroundTaskPortError::NotFound => "NOT_FOUND", + BackgroundTaskPortError::AlreadyExists | BackgroundTaskPortError::Invalid(_) => "INVALID", + BackgroundTaskPortError::Runner(_) => "PROCESS", + BackgroundTaskPortError::Store(_) => "STORE", + }; + ErrorDto { + code: code.to_owned(), + message: err.to_string(), + } +} + +async fn resolve_project_readonly( + project_id: &str, + state: &BackendCore, +) -> Result { + let id = parse_project_id(project_id)?; + state + .open_project + .execute(OpenProjectInput { project_id: id }) + .await + .map(|output| output.project) + .map_err(ErrorDto::from) +} + +fn optional_request(args: &Value) -> Result, ErrorDto> +where + T: for<'de> Deserialize<'de>, +{ + match args { + Value::Object(map) => match map.get("request") { + Some(value) => serde_json::from_value(value.clone()) + .map(Some) + .map_err(invalid_args_error), + None if map.is_empty() => Ok(None), + None => serde_json::from_value(args.clone()) + .map(Some) + .map_err(invalid_args_error), + }, + Value::Null => Ok(None), + _ => Err(ErrorDto { + code: "INVALID".to_owned(), + message: "args must be an object".to_owned(), + }), + } +} + +fn validate_request_origin( + headers: &HeaderMap, + config: &ServerConfig, +) -> Result, Box>> { + let Some(origin) = headers.get(ORIGIN).and_then(|value| value.to_str().ok()) else { + return Err(Box::new(error_response( + StatusCode::FORBIDDEN, + "FORBIDDEN", + "missing Origin header", + None, + ))); + }; + + if origin_allowed(origin, config) { + Ok(Some(origin.to_owned())) + } else { + Err(Box::new(error_response( + StatusCode::FORBIDDEN, + "FORBIDDEN", + "origin not allowed", + None, + ))) + } +} + +fn request_origin(headers: &HeaderMap) -> Option { + headers + .get(ORIGIN) + .and_then(|value| value.to_str().ok()) + .map(str::to_owned) +} + +fn origin_allowed(origin: &str, config: &ServerConfig) -> bool { + if let Some(public_origin) = &config.public_origin { + return origin == public_origin; + } + + if !config.listen.ip().is_loopback() { + return false; + } + + let port = config.listen.port(); + origin == format!("http://127.0.0.1:{port}") + || origin == format!("http://localhost:{port}") + || origin == format!("http://[::1]:{port}") +} + +fn has_forbidden_query_secret(uri: &Uri) -> bool { + uri.query().is_some_and(|query| { + query.split('&').any(|pair| { + let key = pair.split_once('=').map_or(pair, |(key, _)| key); + matches!( + key.to_ascii_lowercase().as_str(), + "token" | "secret" | "session" | "code" + ) + }) + }) +} + +fn session_cookie(headers: &HeaderMap) -> Option { + headers + .get(COOKIE) + .and_then(|value| value.to_str().ok()) + .and_then(|raw| { + raw.split(';').find_map(|part| { + let (name, value) = part.trim().split_once('=')?; + (name == SESSION_COOKIE).then(|| value.to_owned()) + }) + }) +} + +fn json_response( + status: StatusCode, + value: &Value, + origin: Option<&str>, +) -> Response { + let body = serde_json::to_vec(value).expect("JSON value serializes"); + let mut response = Response::new(Full::new(Bytes::from(body))); + *response.status_mut() = status; + response + .headers_mut() + .insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); + add_cors_headers(&mut response, origin); + response +} + +fn error_dto_response( + status: StatusCode, + error: ErrorDto, + origin: Option<&str>, +) -> Response { + let value = serde_json::to_value(error).expect("ErrorDto serializes"); + json_response(status, &value, origin) +} + +fn error_response( + status: StatusCode, + code: impl Into, + message: impl Into, + origin: Option<&str>, +) -> Response { + error_dto_response( + status, + ErrorDto { + code: code.into(), + message: message.into(), + }, + origin, + ) +} + +fn cors_response(status: StatusCode, origin: Option<&str>) -> Response { + let mut response = Response::new(Full::new(Bytes::new())); + *response.status_mut() = status; + add_cors_headers(&mut response, origin); + response +} + +fn add_cors_headers(response: &mut Response, origin: Option<&str>) { + if let Some(origin) = origin { + if let Ok(origin) = HeaderValue::from_str(origin) { + response + .headers_mut() + .insert("access-control-allow-origin", origin); + response.headers_mut().insert( + "access-control-allow-credentials", + HeaderValue::from_static("true"), + ); + response.headers_mut().insert( + "access-control-allow-headers", + HeaderValue::from_static("content-type"), + ); + response.headers_mut().insert( + "access-control-allow-methods", + HeaderValue::from_static("POST, OPTIONS"), + ); + } + } +} + +fn status_for_error(error: &ErrorDto) -> StatusCode { + match error.code.as_str() { + "UNKNOWN_COMMAND" => StatusCode::BAD_REQUEST, + "INVALID" => StatusCode::BAD_REQUEST, + "NOT_FOUND" => StatusCode::NOT_FOUND, + "FORBIDDEN" => StatusCode::FORBIDDEN, + "UNAUTHORIZED" => StatusCode::UNAUTHORIZED, + _ => StatusCode::INTERNAL_SERVER_ERROR, + } +} + +fn validate_origin(origin: &str) -> Result<(), String> { + if origin == "*" || origin.contains('*') { + return Err("--public-origin must be exact, not a wildcard".to_owned()); + } + if origin.ends_with('/') { + return Err("--public-origin must not end with '/'".to_owned()); + } + if !(origin.starts_with("https://") || origin.starts_with("http://")) { + return Err("--public-origin must be an HTTP(S) origin".to_owned()); + } + Ok(()) +} + +fn default_app_data_dir() -> PathBuf { + if let Some(path) = env::var_os("IDEA_APP_DATA_DIR") { + return PathBuf::from(path); + } + if let Some(path) = env::var_os("XDG_DATA_HOME") { + return PathBuf::from(path).join("app.idea.ide"); + } + if let Some(home) = env::var_os("HOME") { + return PathBuf::from(home).join(".local/share/app.idea.ide"); + } + env::current_dir() + .unwrap_or_else(|_| PathBuf::from(".")) + .join(".ideai/app-data") +} + +fn new_pairing_code() -> String { + Uuid::new_v4() + .simple() + .to_string() + .chars() + .take(8) + .collect::() + .to_ascii_uppercase() +} + +fn new_session_token() -> String { + format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple()) +} + +fn invalid_args_error(err: serde_json::Error) -> ErrorDto { + ErrorDto { + code: "INVALID".to_owned(), + message: format!("invalid command args: {err}"), + } +} + +fn serialization_error(err: serde_json::Error) -> ErrorDto { + ErrorDto { + code: "SERIALIZATION".to_owned(), + message: format!("failed to serialize response: {err}"), + } +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +struct ClientFrame { + id: String, + kind: String, + #[serde(default)] + payload: Value, +} + +#[derive(Debug, Clone, Serialize)] +struct ServerFrame { + kind: String, + #[serde(skip_serializing_if = "Option::is_none", rename = "replyTo")] + reply_to: Option, + payload: Value, +} + +impl ServerFrame { + fn attached( + request_id: &str, + session: &TerminalSessionDto, + scrollback: Vec, + next_seq: u64, + gap: bool, + ) -> Self { + let scrollback = if scrollback.is_empty() { + Vec::new() + } else { + vec![json!({ + "seq": 0, + "bytesBase64": base64_encode(&scrollback), + })] + }; + Self { + kind: "terminal.attached".to_owned(), + reply_to: Some(request_id.to_owned()), + payload: json!({ + "session": { + "sessionId": session.session_id, + "rows": session.rows, + "cols": session.cols, + }, + "scrollback": scrollback, + "nextSeq": next_seq, + "status": "running", + "gap": gap, + "assignedConversationId": session.assigned_conversation_id, + }), + } + } + + fn output(session_id: SessionId, seq: u64, bytes: Vec) -> Self { + Self { + kind: "terminal.output".to_owned(), + reply_to: None, + payload: json!({ + "sessionId": session_id.to_string(), + "seq": seq, + "bytesBase64": base64_encode(&bytes), + }), + } + } + + fn status(session_id: SessionId, status: &str, exit_code: Option) -> Self { + Self { + kind: "terminal.status".to_owned(), + reply_to: None, + payload: json!({ + "sessionId": session_id.to_string(), + "status": status, + "exitCode": exit_code, + }), + } + } + + fn domain_event(event: &DomainEvent) -> Self { + Self { + kind: "event.domain".to_owned(), + reply_to: None, + payload: serde_json::to_value(DomainEventDto::from(event)) + .expect("DomainEventDto serializes"), + } + } + + fn error( + request_id: Option, + session_id: Option, + code: impl Into, + message: impl Into, + ) -> Self { + Self { + kind: "error".to_owned(), + reply_to: request_id, + payload: json!({ + "sessionId": session_id, + "code": code.into(), + "message": message.into(), + }), + } + } + + fn pong() -> Self { + Self { + kind: "pong".to_owned(), + reply_to: None, + payload: json!({}), + } + } +} + +struct WsPtySink { + session_id: SessionId, + tx: mpsc::Sender, + seq: AtomicU64, +} + +impl WsPtySink { + fn new(session_id: SessionId, tx: mpsc::Sender, next_seq: u64) -> Self { + Self { + session_id, + tx, + seq: AtomicU64::new(next_seq), + } + } +} + +impl OutputSink for WsPtySink { + fn send(&self, item: PtyChunk) -> Result<(), OutputSinkError> { + let seq = self.seq.fetch_add(1, Ordering::Relaxed); + self.tx + .try_send(ServerFrame::output(self.session_id, seq, item)) + .map_err(|err| match err { + mpsc::error::TrySendError::Full(_) => OutputSinkError::Full, + mpsc::error::TrySendError::Closed(_) => OutputSinkError::Closed, + }) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum WsOpcode { + Text, + Binary, + Close, + Ping, + Pong, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct WsFrame { + opcode: WsOpcode, + payload: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum WsFrameError { + Incomplete, + Protocol(String), + TooLarge, +} + +impl std::fmt::Display for WsFrameError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Incomplete => f.write_str("incomplete websocket frame"), + Self::Protocol(message) => write!(f, "websocket protocol error: {message}"), + Self::TooLarge => f.write_str("websocket frame payload too large"), + } + } +} + +async fn read_ws_frame(reader: &mut R) -> Result +where + R: AsyncRead + Unpin, +{ + let mut header = [0_u8; 2]; + reader + .read_exact(&mut header) + .await + .map_err(|_| WsFrameError::Incomplete)?; + let len_code = header[1] & 0x7f; + let masked = header[1] & 0x80 != 0; + let mut rest = Vec::new(); + let extra_len = match len_code { + 126 => 2, + 127 => 8, + _ => 0, + }; + rest.resize(extra_len + usize::from(masked) * 4, 0); + if !rest.is_empty() { + reader + .read_exact(&mut rest) + .await + .map_err(|_| WsFrameError::Incomplete)?; + } + let mut raw = Vec::with_capacity(2 + rest.len()); + raw.extend_from_slice(&header); + raw.extend_from_slice(&rest); + let payload_len = decoded_payload_len(&raw)?; + if payload_len > WS_MAX_PAYLOAD { + return Err(WsFrameError::TooLarge); + } + let mut payload = vec![0_u8; payload_len]; + reader + .read_exact(&mut payload) + .await + .map_err(|_| WsFrameError::Incomplete)?; + raw.extend_from_slice(&payload); + decode_client_ws_frame(&raw) +} + +fn decode_client_ws_frame(raw: &[u8]) -> Result { + if raw.len() < 2 { + return Err(WsFrameError::Incomplete); + } + let fin = raw[0] & 0x80 != 0; + if !fin { + return Err(WsFrameError::Protocol( + "fragmented frames are not supported".to_owned(), + )); + } + let opcode = match raw[0] & 0x0f { + 0x1 => WsOpcode::Text, + 0x2 => WsOpcode::Binary, + 0x8 => WsOpcode::Close, + 0x9 => WsOpcode::Ping, + 0xA => WsOpcode::Pong, + other => { + return Err(WsFrameError::Protocol(format!( + "unsupported opcode {other}" + ))) + } + }; + let masked = raw[1] & 0x80 != 0; + if !masked { + return Err(WsFrameError::Protocol( + "client frames must be masked".to_owned(), + )); + } + let len_code = raw[1] & 0x7f; + let mut offset = 2; + let len = match len_code { + 126 => { + if raw.len() < offset + 2 { + return Err(WsFrameError::Incomplete); + } + let len = u16::from_be_bytes([raw[offset], raw[offset + 1]]) as usize; + offset += 2; + len + } + 127 => { + if raw.len() < offset + 8 { + return Err(WsFrameError::Incomplete); + } + let len = u64::from_be_bytes(raw[offset..offset + 8].try_into().expect("8 bytes")); + offset += 8; + usize::try_from(len).map_err(|_| WsFrameError::TooLarge)? + } + len => usize::from(len), + }; + if len > WS_MAX_PAYLOAD { + return Err(WsFrameError::TooLarge); + } + if raw.len() < offset + 4 + len { + return Err(WsFrameError::Incomplete); + } + let mask = &raw[offset..offset + 4]; + offset += 4; + let mut payload = raw[offset..offset + len].to_vec(); + for (i, byte) in payload.iter_mut().enumerate() { + *byte ^= mask[i % 4]; + } + Ok(WsFrame { opcode, payload }) +} + +fn decoded_payload_len(raw_header: &[u8]) -> Result { + let len_code = raw_header[1] & 0x7f; + let offset = 2; + let len = match len_code { + 126 => { + if raw_header.len() < offset + 2 { + return Err(WsFrameError::Incomplete); + } + u16::from_be_bytes([raw_header[offset], raw_header[offset + 1]]) as usize + } + 127 => { + if raw_header.len() < offset + 8 { + return Err(WsFrameError::Incomplete); + } + let len = + u64::from_be_bytes(raw_header[offset..offset + 8].try_into().expect("8 bytes")); + usize::try_from(len).map_err(|_| WsFrameError::TooLarge)? + } + len => usize::from(len), + }; + Ok(len) +} + +fn encode_ws_frame(opcode: WsOpcode, payload: &[u8]) -> Vec { + let opcode = match opcode { + WsOpcode::Text => 0x1, + WsOpcode::Binary => 0x2, + WsOpcode::Close => 0x8, + WsOpcode::Ping => 0x9, + WsOpcode::Pong => 0xA, + }; + let mut out = vec![0x80 | opcode]; + match payload.len() { + 0..=125 => out.push(payload.len() as u8), + 126..=65535 => { + out.push(126); + out.extend_from_slice(&(payload.len() as u16).to_be_bytes()); + } + len => { + out.push(127); + out.extend_from_slice(&(len as u64).to_be_bytes()); + } + } + out.extend_from_slice(payload); + out +} + +fn websocket_accept(key: &str) -> String { + let mut input = Vec::with_capacity(key.len() + WS_MAGIC.len()); + input.extend_from_slice(key.as_bytes()); + input.extend_from_slice(WS_MAGIC.as_bytes()); + base64_encode(&sha1_digest(&input)) +} + +fn base64_encode(bytes: &[u8]) -> String { + use base64::Engine as _; + base64::engine::general_purpose::STANDARD.encode(bytes) +} + +fn base64_decode(raw: &str) -> Result, ErrorDto> { + use base64::Engine as _; + base64::engine::general_purpose::STANDARD + .decode(raw) + .map_err(|err| ErrorDto { + code: "INVALID".to_owned(), + message: format!("invalid base64 bytes: {err}"), + }) +} + +fn sha1_digest(input: &[u8]) -> [u8; 20] { + let mut h0: u32 = 0x67452301; + let mut h1: u32 = 0xEFCDAB89; + let mut h2: u32 = 0x98BADCFE; + let mut h3: u32 = 0x10325476; + let mut h4: u32 = 0xC3D2E1F0; + + let bit_len = (input.len() as u64) * 8; + let mut msg = input.to_vec(); + msg.push(0x80); + while (msg.len() % 64) != 56 { + msg.push(0); + } + msg.extend_from_slice(&bit_len.to_be_bytes()); + + for chunk in msg.chunks(64) { + let mut w = [0_u32; 80]; + for (i, word) in w.iter_mut().take(16).enumerate() { + let j = i * 4; + *word = u32::from_be_bytes([chunk[j], chunk[j + 1], chunk[j + 2], chunk[j + 3]]); + } + for i in 16..80 { + w[i] = (w[i - 3] ^ w[i - 8] ^ w[i - 14] ^ w[i - 16]).rotate_left(1); + } + let mut a = h0; + let mut b = h1; + let mut c = h2; + let mut d = h3; + let mut e = h4; + for (i, word) in w.iter().enumerate() { + let (f, k) = match i { + 0..=19 => ((b & c) | ((!b) & d), 0x5A827999), + 20..=39 => (b ^ c ^ d, 0x6ED9EBA1), + 40..=59 => ((b & c) | (b & d) | (c & d), 0x8F1BBCDC), + _ => (b ^ c ^ d, 0xCA62C1D6), + }; + let temp = a + .rotate_left(5) + .wrapping_add(f) + .wrapping_add(e) + .wrapping_add(k) + .wrapping_add(*word); + e = d; + d = c; + c = b.rotate_left(30); + b = a; + a = temp; + } + h0 = h0.wrapping_add(a); + h1 = h1.wrapping_add(b); + h2 = h2.wrapping_add(c); + h3 = h3.wrapping_add(d); + h4 = h4.wrapping_add(e); + } + + let mut out = [0_u8; 20]; + out[0..4].copy_from_slice(&h0.to_be_bytes()); + out[4..8].copy_from_slice(&h1.to_be_bytes()); + out[8..12].copy_from_slice(&h2.to_be_bytes()); + out[12..16].copy_from_slice(&h3.to_be_bytes()); + out[16..20].copy_from_slice(&h4.to_be_bytes()); + out +} + +fn payload_string(payload: &Value, key: &str) -> Result { + payload + .get(key) + .and_then(Value::as_str) + .map(ToOwned::to_owned) + .ok_or_else(|| ErrorDto { + code: "INVALID".to_owned(), + message: format!("payload requires {key}"), + }) +} + +fn payload_u16(payload: &Value, key: &str) -> Option { + payload + .get(key) + .and_then(Value::as_u64) + .and_then(|value| u16::try_from(value).ok()) +} + +fn payload_bytes(payload: &Value) -> Result, ErrorDto> { + if let Some(raw) = payload.get("bytesBase64").and_then(Value::as_str) { + base64_decode(raw) + } else if let Some(raw) = payload.get("bytes").and_then(Value::as_str) { + base64_decode(raw) + } else { + Err(ErrorDto { + code: "INVALID".to_owned(), + message: "payload requires bytesBase64".to_owned(), + }) + } +} + +fn payload_session_id(payload: &Value) -> Option { + payload + .get("sessionId") + .and_then(Value::as_str) + .map(ToOwned::to_owned) +} + +#[cfg(test)] +mod tests { + use super::*; + use application::{ + CreateAgentInput, CreateProjectInput, LaunchAgentOutput, SaveProfileInput, + StructuredSessionDescriptor, + }; + use domain::ports::EventBus; + use domain::{ + AgentId, AgentProfile, BackgroundTask, BackgroundTaskKind, BackgroundTaskWakePolicy, + ContextInjection, NodeId, ProfileId, ProjectId, ProjectPath, PtySize, SessionKind, + SessionStatus, SessionStrategy, TaskId, TerminalSession, + }; + use http::header::HeaderName; + use std::time::Duration; + + static ENV_LOCK: Mutex<()> = Mutex::new(()); + + struct EnvVarGuard { + saved: Vec<(&'static str, Option)>, + } + + impl EnvVarGuard { + fn new(keys: &[&'static str]) -> Self { + Self { + saved: keys + .iter() + .map(|key| (*key, std::env::var_os(key))) + .collect(), + } + } + } + + impl Drop for EnvVarGuard { + fn drop(&mut self) { + for (key, value) in self.saved.iter().rev() { + if let Some(value) = value { + std::env::set_var(key, value); + } else { + std::env::remove_var(key); + } + } + } + } + + #[derive(Default)] + struct RecordingSecurityLogger { + events: Mutex>, + } + + impl RecordingSecurityLogger { + fn events(&self) -> Vec { + self.events.lock().unwrap().clone() + } + } + + impl SecurityLogger for RecordingSecurityLogger { + fn log(&self, event: SecurityLogEvent) { + self.events.lock().unwrap().push(event); + } + } + + fn create_web_root() -> PathBuf { + let root = std::env::temp_dir().join(format!("idea-web-root-{}", Uuid::new_v4())); + std::fs::create_dir_all(root.join("assets")).unwrap(); + std::fs::write( + root.join("index.html"), + r#"
"#, + ) + .unwrap(); + std::fs::write(root.join("assets").join("app.js"), "console.log('idea');").unwrap(); + std::fs::write(root.join("assets").join("style.css"), "body{margin:0}").unwrap(); + root + } + + fn test_config() -> ServerConfig { + ServerConfig { + listen: "127.0.0.1:17373".parse().unwrap(), + public_origin: None, + allow_remote: false, + trust_reverse_proxy: false, + app_data_dir: std::env::temp_dir().join(format!("idea-server-test-{}", Uuid::new_v4())), + web_root: create_web_root(), + } + } + + #[test] + fn default_app_data_dir_uses_tauri_identifier_with_env_precedence() { + let _lock = ENV_LOCK.lock().unwrap(); + let _guard = EnvVarGuard::new(&["IDEA_APP_DATA_DIR", "XDG_DATA_HOME", "HOME"]); + let root = std::env::temp_dir().join(format!("idea-app-data-env-{}", Uuid::new_v4())); + let home = root.join("home"); + let xdg = root.join("xdg"); + let override_dir = root.join("override"); + + std::env::remove_var("IDEA_APP_DATA_DIR"); + std::env::remove_var("XDG_DATA_HOME"); + std::env::set_var("HOME", &home); + assert_eq!( + default_app_data_dir(), + home.join(".local/share/app.idea.ide") + ); + + std::env::set_var("XDG_DATA_HOME", &xdg); + assert_eq!(default_app_data_dir(), xdg.join("app.idea.ide")); + + std::env::set_var("IDEA_APP_DATA_DIR", &override_dir); + assert_eq!(default_app_data_dir(), override_dir); + } + + fn state() -> Arc { + Arc::new(ServerState::new_for_test(test_config(), "PAIR1234")) + } + + async fn request( + state: Arc, + method: Method, + uri: &str, + body: Value, + extra_headers: &[(&str, &str)], + ) -> Response { + let mut builder = Request::builder() + .method(method) + .uri(uri) + .header(ORIGIN, "http://127.0.0.1:17373") + .header(CONTENT_TYPE, "application/json"); + for (name, value) in extra_headers { + builder = builder.header(HeaderName::from_bytes(name.as_bytes()).unwrap(), *value); + } + handle_request( + builder + .body(Full::new(Bytes::from(serde_json::to_vec(&body).unwrap()))) + .unwrap(), + state, + ) + .await + } + + async fn response_json(response: Response) -> (StatusCode, Value, HeaderMap) { + let status = response.status(); + let headers = response.headers().clone(); + let body = response.into_body().collect().await.unwrap().to_bytes(); + let value = if body.is_empty() { + Value::Null + } else { + serde_json::from_slice(&body).unwrap() + }; + (status, value, headers) + } + + async fn response_bytes(response: Response) -> (StatusCode, Bytes, HeaderMap) { + let status = response.status(); + let headers = response.headers().clone(); + let body = response.into_body().collect().await.unwrap().to_bytes(); + (status, body, headers) + } + + async fn pair_and_cookie(state: Arc) -> String { + let response = request( + state, + Method::POST, + "/api/pair", + json!({ "code": "PAIR1234" }), + &[], + ) + .await; + response + .headers() + .get(SET_COOKIE) + .unwrap() + .to_str() + .unwrap() + .split(';') + .next() + .unwrap() + .to_owned() + } + + async fn create_project_for_test(state: &Arc, name: &str) -> String { + let root = std::env::temp_dir() + .join(format!("idea-server-project-{}", Uuid::new_v4())) + .to_string_lossy() + .into_owned(); + let output = state + .app + .create_project + .execute(CreateProjectInput { + name: name.to_owned(), + root, + remote: None, + default_profile_id: None, + }) + .await + .expect("test project is created"); + output.project.id.to_string() + } + + async fn create_background_task_for_test( + state: &Arc, + project_id: &str, + label: &str, + ) -> (TaskId, AgentId) { + let task_id = TaskId::from_uuid(Uuid::new_v4()); + let owner = AgentId::from_uuid(Uuid::new_v4()); + let project_id = ProjectId::from_uuid(Uuid::parse_str(project_id).unwrap()); + let task = BackgroundTask::new( + task_id, + project_id, + owner, + BackgroundTaskKind::Command { + label: label.to_owned(), + }, + BackgroundTaskWakePolicy::WakeOwner, + 1_000, + None, + ) + .expect("test background task is valid"); + state + .app + .background_task_store + .create(&task) + .await + .expect("test background task is stored"); + (task_id, owner) + } + + async fn create_raw_cli_agent_for_test( + state: &Arc, + name: &str, + ) -> (String, String) { + let project_id = create_project_for_test(state, name).await; + let project = resolve_project_readonly(&project_id, &state.app) + .await + .expect("test project resolves"); + let profile_id = ProfileId::from_uuid(Uuid::new_v4()); + let profile = AgentProfile::new( + profile_id, + format!("{name} shell profile"), + "/bin/sh", + vec![ + "-c".to_owned(), + "printf agent-ready; sleep 30".to_owned(), + "idea-test-sh".to_owned(), + ], + ContextInjection::env("IDEA_CONTEXT").expect("valid env injection"), + None, + "{agentRunDir}", + Some( + SessionStrategy::new(Some("--session-id".to_owned()), "--resume") + .expect("valid session strategy"), + ), + ) + .expect("valid raw CLI profile"); + state + .app + .save_profile + .execute(SaveProfileInput { profile }) + .await + .expect("test profile saved"); + let agent = state + .app + .create_agent + .execute(CreateAgentInput { + project, + name: format!("{name} agent"), + profile_id, + initial_content: Some("Test agent context".to_owned()), + }) + .await + .expect("test agent created") + .agent; + (project_id, agent.id.to_string()) + } + + fn ws_headers(origin: &str, cookie: Option<&str>) -> HeaderMap { + let mut headers = HeaderMap::new(); + headers.insert(ORIGIN, HeaderValue::from_str(origin).unwrap()); + headers.insert("upgrade", HeaderValue::from_static("websocket")); + headers.insert("connection", HeaderValue::from_static("Upgrade")); + headers.insert( + "sec-websocket-key", + HeaderValue::from_static("dGhlIHNhbXBsZSBub25jZQ=="), + ); + if let Some(cookie) = cookie { + headers.insert(COOKIE, HeaderValue::from_str(cookie).unwrap()); + } + headers + } + + fn masked_text_frame(text: &str) -> Vec { + let payload = text.as_bytes(); + let mask = [1_u8, 2, 3, 4]; + let mut out = vec![0x81]; + out.push(0x80 | payload.len() as u8); + out.extend_from_slice(&mask); + for (i, byte) in payload.iter().enumerate() { + out.push(byte ^ mask[i % 4]); + } + out + } + + async fn recv_server_frame(rx: &mut mpsc::Receiver) -> ServerFrame { + tokio::time::timeout(Duration::from_secs(2), rx.recv()) + .await + .expect("server frame received before timeout") + .expect("server frame channel is open") + } + + async fn recv_server_frame_where( + rx: &mut mpsc::Receiver, + mut predicate: impl FnMut(&ServerFrame) -> bool, + ) -> ServerFrame { + for _ in 0..16 { + let frame = recv_server_frame(rx).await; + if predicate(&frame) { + return frame; + } + } + panic!("matching server frame was not received"); + } + + fn drain_server_frames(rx: &mut mpsc::Receiver) { + while rx.try_recv().is_ok() {} + } + + fn ws_frame(id: &str, kind: &str, payload: Value) -> ClientFrame { + ClientFrame { + id: id.to_owned(), + kind: kind.to_owned(), + payload, + } + } + + fn attached_session_id(frame: &ServerFrame) -> String { + assert_eq!(frame.kind, "terminal.attached"); + frame.payload["session"]["sessionId"] + .as_str() + .expect("attached frame carries sessionId") + .to_owned() + } + + fn open_terminal_payload(command: &str, args: Vec<&str>) -> Value { + json!({ + "request": { + "cwd": std::env::temp_dir().to_string_lossy(), + "rows": 24, + "cols": 80, + "command": command, + "args": args, + } + }) + } + + fn agent_launch_payload( + project_id: &str, + agent_id: &str, + node_id: &str, + conversation_id: Option<&str>, + ) -> Value { + json!({ + "projectId": project_id, + "agentId": agent_id, + "nodeId": node_id, + "rows": 24, + "cols": 80, + "conversationId": conversation_id, + }) + } + + async fn close_ws_session(state: &Arc, session_id: &str) { + let (tx, mut rx) = mpsc::channel(8); + let owned = Arc::new(Mutex::new(Vec::new())); + handle_client_frame( + ws_frame( + "close-cleanup", + "terminal.close", + json!({ "sessionId": session_id }), + ), + state, + &tx, + &owned, + ) + .await; + let _ = recv_server_frame(&mut rx).await; + } + + async fn wait_for_scrollback( + state: &Arc, + session_id: &str, + needle: &[u8], + ) -> Vec { + let sid = parse_session_id(session_id).expect("test session id parses"); + let handle = PtyHandle { session_id: sid }; + for _ in 0..100 { + if let Ok(scrollback) = state.app.pty_port.scrollback(&handle) { + if scrollback + .windows(needle.len()) + .any(|window| window == needle) + { + return scrollback; + } + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + panic!( + "scrollback did not contain {:?}", + String::from_utf8_lossy(needle) + ); + } + + #[test] + fn websocket_accept_matches_rfc_example() { + assert_eq!( + websocket_accept("dGhlIHNhbXBsZSBub25jZQ=="), + "s3pPLMBiTxaQ9kYGzzhZRbK+xOo=" + ); + } + + #[test] + fn websocket_upgrade_requires_valid_cookie() { + let state = state(); + let headers = ws_headers("http://127.0.0.1:17373", None); + + let response = validate_ws_upgrade(&headers, &state).unwrap_err(); + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } + + #[test] + fn websocket_upgrade_rejects_invalid_cookie() { + let state = state(); + let headers = ws_headers( + "http://127.0.0.1:17373", + Some("idea_session=not-a-valid-session"), + ); + + let response = validate_ws_upgrade(&headers, &state).unwrap_err(); + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } + + #[test] + fn websocket_upgrade_requires_allowed_origin() { + let state = state(); + let token = state.create_session(); + let headers = ws_headers( + "https://evil.example", + Some(&format!("idea_session={token}")), + ); + + let response = validate_ws_upgrade(&headers, &state).unwrap_err(); + + assert_eq!(response.status(), StatusCode::FORBIDDEN); + } + + #[test] + fn websocket_upgrade_accepts_valid_cookie_and_origin() { + let state = state(); + let token = state.create_session(); + let headers = ws_headers( + "http://127.0.0.1:17373", + Some(&format!("idea_session={token}")), + ); + + let accept = validate_ws_upgrade(&headers, &state).unwrap(); + + assert_eq!(accept, "s3pPLMBiTxaQ9kYGzzhZRbK+xOo="); + } + + #[test] + fn websocket_client_text_frame_decodes_masked_json() { + let raw = masked_text_frame(r#"{"kind":"ping"}"#); + + let frame = decode_client_ws_frame(&raw).unwrap(); + + assert_eq!(frame.opcode, WsOpcode::Text); + assert_eq!(frame.payload, br#"{"kind":"ping"}"#); + } + + #[test] + fn websocket_client_json_frame_round_trips_base64_bytes() { + let sid = domain::SessionId::new_random().to_string(); + let value = json!({ + "id": "req-1", + "kind": "input", + "payload": { + "sessionId": sid, + "bytesBase64": "AQID" + } + }); + + let frame: ClientFrame = serde_json::from_value(value.clone()).unwrap(); + let roundtrip = serde_json::to_value(&frame).unwrap(); + + assert_eq!(roundtrip, value); + assert_eq!(payload_bytes(&frame.payload).unwrap(), vec![1, 2, 3]); + } + + #[test] + fn websocket_server_attached_frame_uses_contract_shape() { + let sid = domain::SessionId::new_random().to_string(); + let frame = ServerFrame::attached( + "req-attach", + &TerminalSessionDto { + session_id: sid.clone(), + cwd: "/tmp".to_owned(), + rows: 24, + cols: 80, + assigned_conversation_id: None, + engine_session_id: None, + cell_kind: backend::dto::CellKind::Pty, + }, + vec![4, 5, 6], + 1, + true, + ); + let value = serde_json::to_value(frame).unwrap(); + + assert_eq!(value["kind"], "terminal.attached"); + assert_eq!(value["replyTo"], "req-attach"); + assert_eq!(value["payload"]["session"]["sessionId"], sid); + assert_eq!(value["payload"]["scrollback"][0]["seq"], 0); + assert_eq!(value["payload"]["scrollback"][0]["bytesBase64"], "BAUG"); + assert_eq!(value["payload"]["nextSeq"], 1); + assert_eq!(value["payload"]["gap"], true); + } + + #[tokio::test] + async fn websocket_app_ping_emits_pong() { + let state = state(); + let (tx, mut rx) = mpsc::channel(4); + let owned = Arc::new(Mutex::new(Vec::new())); + + handle_client_frame(ws_frame("ping-1", "ping", json!({})), &state, &tx, &owned).await; + let frame = recv_server_frame(&mut rx).await; + + assert_eq!(frame.kind, "pong"); + assert!(frame.payload.as_object().unwrap().is_empty()); + } + + #[test] + fn websocket_domain_event_frame_uses_contract_shape() { + let project_id = ProjectId::from_uuid(Uuid::new_v4()); + let frame = ServerFrame::domain_event(&DomainEvent::ProjectCreated { project_id }); + + assert_eq!(frame.kind, "event.domain"); + assert!(frame.reply_to.is_none()); + assert_eq!(frame.payload["type"], "projectCreated"); + assert_eq!(frame.payload["projectId"], project_id.to_string()); + } + + #[tokio::test] + async fn websocket_domain_event_relay_forwards_domain_events() { + let state = state(); + let project_id = ProjectId::from_uuid(Uuid::new_v4()); + let event_rx = state.app.event_bus.raw_receiver(); + let (tx, mut rx) = mpsc::channel(4); + let relay = spawn_ws_domain_event_relay(event_rx, tx); + + state + .app + .event_bus + .publish(DomainEvent::ProjectCreated { project_id }); + let frame = tokio::time::timeout(Duration::from_secs(1), rx.recv()) + .await + .expect("event frame is forwarded") + .expect("event frame channel is open"); + relay.abort(); + + assert_eq!(frame.kind, "event.domain"); + assert_eq!(frame.payload["type"], "projectCreated"); + assert_eq!(frame.payload["projectId"], project_id.to_string()); + } + + #[tokio::test] + async fn websocket_domain_event_relay_forwards_background_completion() { + let state = state(); + let project_id = ProjectId::from_uuid(Uuid::new_v4()); + let task_id = TaskId::from_uuid(Uuid::new_v4()); + let owner = AgentId::from_uuid(Uuid::new_v4()); + let event_rx = state.app.event_bus.raw_receiver(); + let (tx, mut rx) = mpsc::channel(4); + let relay = spawn_ws_domain_event_relay(event_rx, tx); + + state + .app + .event_bus + .publish(DomainEvent::BackgroundTaskCompleted { + project_id, + task_id, + owner_agent_id: owner, + }); + let frame = tokio::time::timeout(Duration::from_secs(1), rx.recv()) + .await + .expect("background completion event is forwarded") + .expect("event frame channel is open"); + relay.abort(); + + assert_eq!(frame.kind, "event.domain"); + assert_eq!(frame.payload["type"], "backgroundTaskChanged"); + assert_eq!(frame.payload["projectId"], project_id.to_string()); + assert_eq!(frame.payload["taskId"], task_id.to_string()); + assert_eq!(frame.payload["agentId"], owner.to_string()); + assert_eq!(frame.payload["state"], "completed"); + } + + #[tokio::test] + async fn websocket_domain_event_relay_excludes_pty_output() { + let state = state(); + let project_id = ProjectId::from_uuid(Uuid::new_v4()); + let event_rx = state.app.event_bus.raw_receiver(); + let (tx, mut rx) = mpsc::channel(4); + let relay = spawn_ws_domain_event_relay(event_rx, tx); + + state.app.event_bus.publish(DomainEvent::PtyOutput { + session_id: SessionId::new_random(), + bytes: b"hidden from global live bus".to_vec(), + }); + state + .app + .event_bus + .publish(DomainEvent::ProjectCreated { project_id }); + let frame = tokio::time::timeout(Duration::from_secs(1), rx.recv()) + .await + .expect("sentinel event is forwarded") + .expect("event frame channel is open"); + relay.abort(); + + assert_eq!(frame.kind, "event.domain"); + assert_eq!(frame.payload["type"], "projectCreated"); + assert_eq!(frame.payload["projectId"], project_id.to_string()); + assert!( + rx.try_recv().is_err(), + "PtyOutput must not be emitted on the global event.domain stream" + ); + } + + #[tokio::test] + async fn websocket_domain_event_relay_drops_when_client_queue_is_full() { + let state = state(); + let event_rx = state.app.event_bus.raw_receiver(); + let (tx, mut rx) = mpsc::channel(1); + assert!(tx.try_send(ServerFrame::pong()).is_ok()); + let relay = spawn_ws_domain_event_relay(event_rx, tx); + + state.app.event_bus.publish(DomainEvent::ProjectCreated { + project_id: ProjectId::from_uuid(Uuid::new_v4()), + }); + tokio::time::sleep(Duration::from_millis(20)).await; + let first = rx.try_recv().expect("pre-filled frame remains queued"); + relay.abort(); + + assert_eq!(first.kind, "pong"); + assert!( + rx.try_recv().is_err(), + "full client queue should drop the live event instead of buffering" + ); + } + + #[tokio::test] + async fn websocket_open_terminal_emits_attached_with_empty_scrollback() { + let state = state(); + let (tx, mut rx) = mpsc::channel(16); + let owned = Arc::new(Mutex::new(Vec::new())); + + handle_client_frame( + ws_frame( + "open-1", + "terminal.open", + open_terminal_payload("/bin/sh", vec!["-c", "sleep 30"]), + ), + &state, + &tx, + &owned, + ) + .await; + let frame = recv_server_frame(&mut rx).await; + let session_id = attached_session_id(&frame); + + assert_eq!(frame.reply_to.as_deref(), Some("open-1")); + assert_eq!(frame.payload["scrollback"].as_array().unwrap().len(), 0); + assert_eq!(frame.payload["nextSeq"], 0); + + close_ws_session(&state, &session_id).await; + } + + #[tokio::test] + async fn websocket_attach_terminal_replays_scrollback_in_attached_ack() { + let state = state(); + let (tx, mut rx) = mpsc::channel(32); + let owned = Arc::new(Mutex::new(Vec::new())); + + handle_client_frame( + ws_frame( + "open-replay", + "terminal.open", + open_terminal_payload("/bin/sh", vec!["-c", "printf replay-ready; sleep 30"]), + ), + &state, + &tx, + &owned, + ) + .await; + let opened = recv_server_frame(&mut rx).await; + let session_id = attached_session_id(&opened); + + let scrollback = wait_for_scrollback(&state, &session_id, b"replay-ready").await; + assert!(scrollback.len() <= 100 * 1024); + drain_server_frames(&mut rx); + + handle_client_frame( + ws_frame( + "attach-replay", + "terminal.attach", + json!({ + "sessionId": session_id, + "lastSeq": 0, + "rows": 30, + "cols": 100, + }), + ), + &state, + &tx, + &owned, + ) + .await; + let attached = recv_server_frame(&mut rx).await; + let replay = attached.payload["scrollback"].as_array().unwrap(); + + assert_eq!(attached.kind, "terminal.attached"); + assert_eq!(attached.reply_to.as_deref(), Some("attach-replay")); + assert_eq!(attached.payload["session"]["rows"], 30); + assert_eq!(attached.payload["session"]["cols"], 100); + assert_eq!(attached.payload["gap"], true); + assert_eq!(attached.payload["nextSeq"], 1); + assert_eq!( + base64_decode(replay[0]["bytesBase64"].as_str().unwrap()).unwrap(), + scrollback + ); + + close_ws_session( + &state, + attached.payload["session"]["sessionId"].as_str().unwrap(), + ) + .await; + } + + #[tokio::test] + async fn websocket_close_terminal_emits_exited_status_and_releases_session() { + let state = state(); + let (tx, mut rx) = mpsc::channel(16); + let owned = Arc::new(Mutex::new(Vec::new())); + + handle_client_frame( + ws_frame( + "open-close", + "terminal.open", + open_terminal_payload("/bin/sh", vec!["-c", "sleep 30"]), + ), + &state, + &tx, + &owned, + ) + .await; + let opened = recv_server_frame(&mut rx).await; + let session_id = attached_session_id(&opened); + + handle_client_frame( + ws_frame( + "close-1", + "terminal.close", + json!({ "sessionId": session_id }), + ), + &state, + &tx, + &owned, + ) + .await; + let status = recv_server_frame(&mut rx).await; + + assert_eq!(status.kind, "terminal.status"); + assert_eq!(status.payload["status"], "exited"); + assert_eq!(status.payload["sessionId"], session_id); + assert_eq!(state.ws_pty_bridge.active_sessions(), 0); + assert!(state.app.terminal_sessions.is_empty()); + } + + #[tokio::test] + async fn websocket_launch_agent_emits_attached_with_assigned_conversation_id() { + let state = state(); + let (project_id, agent_id) = create_raw_cli_agent_for_test(&state, "ws-launch").await; + let node_id = Uuid::new_v4().to_string(); + let (tx, mut rx) = mpsc::channel(32); + let owned = Arc::new(Mutex::new(Vec::new())); + + handle_client_frame( + ws_frame( + "agent-launch-1", + "agent.launch", + agent_launch_payload(&project_id, &agent_id, &node_id, None), + ), + &state, + &tx, + &owned, + ) + .await; + let attached = recv_server_frame(&mut rx).await; + let session_id = attached_session_id(&attached); + + assert_eq!(attached.kind, "terminal.attached"); + assert_eq!(attached.reply_to.as_deref(), Some("agent-launch-1")); + assert_eq!(attached.payload["scrollback"].as_array().unwrap().len(), 0); + assert!(attached.payload["assignedConversationId"] + .as_str() + .and_then(|raw| Uuid::parse_str(raw).ok()) + .is_some()); + + close_ws_session(&state, &session_id).await; + } + + #[tokio::test] + async fn websocket_launch_agent_structured_is_unsupported() { + let state = state(); + let (tx, mut rx) = mpsc::channel(4); + let owned = Arc::new(Mutex::new(Vec::new())); + let session_id = SessionId::new_random(); + let agent_id = AgentId::from_uuid(Uuid::new_v4()); + let node_id = NodeId::from_uuid(Uuid::new_v4()); + let size = PtySize::new(24, 80).unwrap(); + let mut session = TerminalSession::starting( + session_id, + node_id, + ProjectPath::new("/".to_owned()).unwrap(), + SessionKind::Agent { agent_id }, + size, + ); + session.status = SessionStatus::Running; + let frame = ws_frame("agent-structured", "agent.launch", json!({})); + let output = LaunchAgentOutput { + session, + assigned_conversation_id: Some("pair-conversation".to_owned()), + engine_session_id: Some("engine-session".to_owned()), + structured: Some(StructuredSessionDescriptor { + session_id, + agent_id, + node_id, + conversation_id: Some("engine-session".to_owned()), + }), + profile: None, + }; + + if let Err(err) = send_launch_agent_attached(&frame, &state, &tx, &owned, output).await { + tx.send(ServerFrame::error( + Some(frame.id), + payload_session_id(&frame.payload), + err.code, + err.message, + )) + .await + .unwrap(); + } + let error = recv_server_frame(&mut rx).await; + + assert_eq!(error.kind, "error"); + assert_eq!(error.reply_to.as_deref(), Some("agent-structured")); + assert_eq!(error.payload["code"], "UNSUPPORTED"); + assert_eq!( + error.payload["message"], + "structured agent sessions do not stream over the PTY websocket" + ); + assert_eq!(state.ws_pty_bridge.active_sessions(), 0); + } + + #[tokio::test] + async fn websocket_launch_agent_reattach_replays_scrollback_without_respawn() { + let state = state(); + let (project_id, agent_id) = create_raw_cli_agent_for_test(&state, "ws-reattach").await; + let node_id = Uuid::new_v4().to_string(); + let (tx, mut rx) = mpsc::channel(32); + let owned = Arc::new(Mutex::new(Vec::new())); + + handle_client_frame( + ws_frame( + "agent-launch-reattach", + "agent.launch", + agent_launch_payload(&project_id, &agent_id, &node_id, None), + ), + &state, + &tx, + &owned, + ) + .await; + let launched = recv_server_frame(&mut rx).await; + let session_id = attached_session_id(&launched); + let scrollback = wait_for_scrollback(&state, &session_id, b"agent-ready").await; + drain_server_frames(&mut rx); + + handle_client_frame( + ws_frame( + "agent-attach", + "terminal.attach", + json!({ "sessionId": session_id, "lastSeq": 0, "rows": 24, "cols": 80 }), + ), + &state, + &tx, + &owned, + ) + .await; + let attached = recv_server_frame_where(&mut rx, |frame| { + frame.kind == "terminal.attached" && frame.reply_to.as_deref() == Some("agent-attach") + }) + .await; + let replay = attached.payload["scrollback"].as_array().unwrap(); + + assert_eq!(attached.kind, "terminal.attached"); + assert_eq!(attached.payload["session"]["sessionId"], session_id); + assert_eq!( + base64_decode(replay[0]["bytesBase64"].as_str().unwrap()).unwrap(), + scrollback + ); + let aid = parse_agent_id(&agent_id).unwrap(); + assert_eq!( + state.app.terminal_sessions.sessions_for_agent(&aid).len(), + 1 + ); + + close_ws_session(&state, &session_id).await; + } + + #[tokio::test] + async fn websocket_launch_agent_same_cell_is_idempotent_singleton() { + let state = state(); + let (project_id, agent_id) = create_raw_cli_agent_for_test(&state, "ws-singleton").await; + let node_id = Uuid::new_v4().to_string(); + let (tx, mut rx) = mpsc::channel(32); + let owned = Arc::new(Mutex::new(Vec::new())); + + handle_client_frame( + ws_frame( + "agent-launch-first", + "agent.launch", + agent_launch_payload(&project_id, &agent_id, &node_id, None), + ), + &state, + &tx, + &owned, + ) + .await; + let first = recv_server_frame(&mut rx).await; + let first_session = attached_session_id(&first); + + handle_client_frame( + ws_frame( + "agent-launch-second", + "agent.launch", + agent_launch_payload( + &project_id, + &agent_id, + &node_id, + first.payload["assignedConversationId"].as_str(), + ), + ), + &state, + &tx, + &owned, + ) + .await; + let second = recv_server_frame_where(&mut rx, |frame| { + frame.kind == "terminal.attached" + && frame.reply_to.as_deref() == Some("agent-launch-second") + }) + .await; + + assert_eq!(second.kind, "terminal.attached"); + assert_eq!(attached_session_id(&second), first_session); + let aid = parse_agent_id(&agent_id).unwrap(); + assert_eq!( + state.app.terminal_sessions.sessions_for_agent(&aid).len(), + 1 + ); + + close_ws_session(&state, &first_session).await; + } + + #[tokio::test] + async fn websocket_launch_agent_different_cell_is_refused_by_singleton_guard() { + let state = state(); + let (project_id, agent_id) = + create_raw_cli_agent_for_test(&state, "ws-singleton-refuse").await; + let first_node = Uuid::new_v4().to_string(); + let second_node = Uuid::new_v4().to_string(); + let (tx, mut rx) = mpsc::channel(32); + let owned = Arc::new(Mutex::new(Vec::new())); + + handle_client_frame( + ws_frame( + "agent-launch-first-cell", + "agent.launch", + agent_launch_payload(&project_id, &agent_id, &first_node, None), + ), + &state, + &tx, + &owned, + ) + .await; + let first = recv_server_frame(&mut rx).await; + let session_id = attached_session_id(&first); + + handle_client_frame( + ws_frame( + "agent-launch-other-cell", + "agent.launch", + agent_launch_payload(&project_id, &agent_id, &second_node, None), + ), + &state, + &tx, + &owned, + ) + .await; + let error = recv_server_frame_where(&mut rx, |frame| { + frame.kind == "error" && frame.reply_to.as_deref() == Some("agent-launch-other-cell") + }) + .await; + + assert_eq!(error.kind, "error"); + assert_eq!(error.reply_to.as_deref(), Some("agent-launch-other-cell")); + assert_eq!(error.payload["code"], "AGENT_ALREADY_RUNNING"); + let aid = parse_agent_id(&agent_id).unwrap(); + assert_eq!( + state.app.terminal_sessions.sessions_for_agent(&aid).len(), + 1 + ); + + close_ws_session(&state, &session_id).await; + } + + #[test] + fn websocket_client_unmasked_frame_is_rejected() { + let raw = [0x81, 0x02, b'h', b'i']; + + let err = decode_client_ws_frame(&raw).unwrap_err(); + + assert!(matches!(err, WsFrameError::Protocol(_))); + } + + #[test] + fn websocket_payload_too_large_is_rejected() { + let len = (WS_MAX_PAYLOAD + 1) as u64; + let mut raw = vec![0x81, 0x80 | 127]; + raw.extend_from_slice(&len.to_be_bytes()); + raw.extend_from_slice(&[1, 2, 3, 4]); + + let err = decode_client_ws_frame(&raw).unwrap_err(); + + assert_eq!(err, WsFrameError::TooLarge); + } + + #[test] + fn websocket_server_frames_are_unmasked() { + let raw = encode_ws_frame(WsOpcode::Text, b"{}"); + + assert_eq!(raw[0], 0x81); + assert_eq!(raw[1] & 0x80, 0, "server frames must not be masked"); + } + + #[tokio::test] + async fn websocket_sink_full_reports_backpressure_without_blocking() { + let (tx, mut rx) = mpsc::channel(1); + let sid = domain::SessionId::new_random(); + let sink = WsPtySink::new(sid, tx, 0); + + assert!(sink.send(vec![1]).is_ok()); + assert_eq!(sink.send(vec![2]), Err(OutputSinkError::Full)); + let frame = rx.recv().await.unwrap(); + assert_eq!(frame.kind, "terminal.output"); + } + + #[tokio::test] + async fn websocket_output_bridge_replaces_attachment_without_double_delivery() { + let bridge = OutputBridge::::new(); + let sid = domain::SessionId::new_random(); + let (old_tx, mut old_rx) = mpsc::channel(4); + let (new_tx, mut new_rx) = mpsc::channel(4); + + let old_gen = bridge.register(sid, Arc::new(WsPtySink::new(sid, old_tx, 0))); + let _new_gen = bridge.register(sid, Arc::new(WsPtySink::new(sid, new_tx, 0))); + + assert!(bridge.send_output(&sid, vec![9])); + assert!(old_rx.try_recv().is_err()); + let frame = new_rx.recv().await.unwrap(); + assert_eq!(frame.kind, "terminal.output"); + assert_eq!(frame.payload["bytesBase64"], "CQ=="); + + bridge.unregister_if(&sid, old_gen); + assert_eq!(bridge.active_sessions(), 1); + } + + #[test] + fn public_bind_requires_explicit_remote_security() { + let config = ServerConfig { + listen: "0.0.0.0:17373".parse().unwrap(), + ..test_config() + }; + + assert!(config.validate().is_err()); + + let config = ServerConfig { + allow_remote: true, + public_origin: Some("https://idea.example.com".to_owned()), + trust_reverse_proxy: true, + ..config + }; + assert!(config.validate().is_ok()); + } + + #[test] + fn allow_remote_requires_https_origin_and_reverse_proxy() { + let base = ServerConfig { + listen: "0.0.0.0:17373".parse().unwrap(), + allow_remote: true, + ..test_config() + }; + + assert!(base.validate().is_err()); + + let with_http = ServerConfig { + public_origin: Some("http://idea.example.com".to_owned()), + trust_reverse_proxy: true, + ..base.clone() + }; + assert!(with_http.validate().is_err()); + + let without_proxy = ServerConfig { + public_origin: Some("https://idea.example.com".to_owned()), + trust_reverse_proxy: false, + ..base + }; + assert!(without_proxy.validate().is_err()); + } + + #[tokio::test] + async fn invoke_requires_valid_cookie() { + let state = state(); + + let response = request( + state, + Method::POST, + "/api/invoke", + json!({ "command": "health", "args": {} }), + &[], + ) + .await; + let (status, body, _) = response_json(response).await; + + assert_eq!(status, StatusCode::UNAUTHORIZED); + assert_eq!(body["code"], "UNAUTHORIZED"); + } + + #[tokio::test] + async fn invoke_rejects_invalid_session_cookie() { + let state = state(); + + let response = request( + state, + Method::POST, + "/api/invoke", + json!({ "command": "health", "args": {} }), + &[("cookie", "idea_session=not-a-valid-session")], + ) + .await; + let (status, body, _) = response_json(response).await; + + assert_eq!(status, StatusCode::UNAUTHORIZED); + assert_eq!(body["code"], "UNAUTHORIZED"); + } + + #[tokio::test] + async fn logout_revokes_session_for_http_and_websocket() { + let state = state(); + let cookie = pair_and_cookie(Arc::clone(&state)).await; + + let response = request( + Arc::clone(&state), + Method::POST, + "/api/logout", + json!({}), + &[("cookie", &cookie)], + ) + .await; + let (status, body, headers) = response_json(response).await; + + assert_eq!(status, StatusCode::OK); + assert_eq!(body, json!({ "revoked": true })); + assert!(headers + .get(SET_COOKIE) + .unwrap() + .to_str() + .unwrap() + .contains("Max-Age=0")); + + let response = request( + Arc::clone(&state), + Method::POST, + "/api/invoke", + json!({ "command": "health", "args": {} }), + &[("cookie", &cookie)], + ) + .await; + let (status, body, _) = response_json(response).await; + assert_eq!(status, StatusCode::UNAUTHORIZED); + assert_eq!(body["code"], "UNAUTHORIZED"); + + let headers = ws_headers("http://127.0.0.1:17373", Some(&cookie)); + let response = validate_ws_upgrade(&headers, &state).unwrap_err(); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn security_logs_pairing_origin_ws_and_revoke_without_secrets() { + let logger = Arc::new(RecordingSecurityLogger::default()); + let state = Arc::new(ServerState::new_for_test_with_logger( + test_config(), + "PAIR1234", + logger.clone(), + )); + + let bad_origin = handle_request( + Request::builder() + .method(Method::POST) + .uri("/api/pair") + .header(ORIGIN, "https://evil.example") + .header(CONTENT_TYPE, "application/json") + .body(Full::new(Bytes::from_static(br#"{"code":"PAIR1234"}"#))) + .unwrap(), + Arc::clone(&state), + ) + .await; + assert_eq!(bad_origin.status(), StatusCode::FORBIDDEN); + + let wrong_code = request( + Arc::clone(&state), + Method::POST, + "/api/pair", + json!({ "code": "WRONG999" }), + &[], + ) + .await; + assert_eq!(wrong_code.status(), StatusCode::FORBIDDEN); + + let cookie = pair_and_cookie(Arc::clone(&state)).await; + let _ = request( + Arc::clone(&state), + Method::POST, + "/api/logout", + json!({}), + &[("cookie", &cookie)], + ) + .await; + + let headers = ws_headers( + "http://127.0.0.1:17373", + Some("idea_session=invalid-token-value"), + ); + let _ = validate_ws_upgrade(&headers, &state); + + let events = logger.events(); + assert!(events.iter().any(|event| matches!( + event, + SecurityLogEvent::OriginRejected { route, .. } if route == "/api/pair" + ))); + assert!(events.iter().any(|event| matches!( + event, + SecurityLogEvent::PairingFailed { + reason: "wrong_code", + .. + } + ))); + assert!(events + .iter() + .any(|event| matches!(event, SecurityLogEvent::PairingSucceeded { .. }))); + assert!(events + .iter() + .any(|event| matches!(event, SecurityLogEvent::SessionRevoked { .. }))); + assert!(events.iter().any(|event| matches!( + event, + SecurityLogEvent::WsUpgradeRejected { + reason: "invalid_session", + .. + } + ))); + + let lines = events.iter().map(security_log_line).collect::>(); + assert!( + !lines.iter().any(|line| line.contains("PAIR1234") + || line.contains("WRONG999") + || line.contains("invalid-token-value")), + "security logs must not leak pairing codes or session tokens" + ); + } + + #[tokio::test] + async fn pairing_rejects_wrong_code() { + let state = state(); + + let response = request( + state, + Method::POST, + "/api/pair", + json!({ "code": "WRONG999" }), + &[], + ) + .await; + let (status, body, headers) = response_json(response).await; + + assert_eq!(status, StatusCode::FORBIDDEN); + assert_eq!(body["code"], "FORBIDDEN"); + assert!( + !headers.contains_key(SET_COOKIE), + "wrong pairing code must not issue a session cookie" + ); + } + + #[tokio::test] + async fn pairing_sets_http_only_strict_cookie_for_loopback_dev() { + let state = state(); + + let response = request( + state, + Method::POST, + "/api/pair", + json!({ "code": "PAIR1234" }), + &[], + ) + .await; + let (status, body, headers) = response_json(response).await; + + assert_eq!(status, StatusCode::OK); + assert_eq!(body, json!({ "paired": true })); + let cookie = headers.get(SET_COOKIE).unwrap().to_str().unwrap(); + assert!(cookie.contains("idea_session=")); + assert!(cookie.contains("HttpOnly")); + assert!(cookie.contains("SameSite=Strict")); + assert!( + !cookie.contains("Secure"), + "loopback dev over HTTP deliberately avoids Secure so browsers store it" + ); + } + + #[tokio::test] + async fn pairing_sets_secure_cookie_for_remote_https_origin() { + let config = ServerConfig { + listen: "0.0.0.0:17373".parse().unwrap(), + public_origin: Some("https://idea.example.com".to_owned()), + allow_remote: true, + trust_reverse_proxy: true, + ..test_config() + }; + let state = Arc::new(ServerState::new_for_test(config, "PAIR1234")); + + let mut req = Request::builder() + .method(Method::POST) + .uri("/api/pair") + .header(ORIGIN, "https://idea.example.com") + .header(CONTENT_TYPE, "application/json") + .body(Full::new(Bytes::from_static(br#"{"code":"PAIR1234"}"#))) + .unwrap(); + *req.headers_mut().get_mut(CONTENT_TYPE).unwrap() = + HeaderValue::from_static("application/json"); + + let response = handle_request(req, state).await; + let (status, _, headers) = response_json(response).await; + + assert_eq!(status, StatusCode::OK); + let cookie = headers.get(SET_COOKIE).unwrap().to_str().unwrap(); + assert!(cookie.contains("Secure")); + assert!(cookie.contains("HttpOnly")); + assert!(cookie.contains("SameSite=Strict")); + } + + #[tokio::test] + async fn token_or_secret_in_query_string_is_refused() { + let state = state(); + + let response = request( + state, + Method::POST, + "/api/invoke?token=abc", + json!({ "command": "health", "args": {} }), + &[], + ) + .await; + let (status, body, _) = response_json(response).await; + + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!(body["code"], "INVALID"); + } + + #[tokio::test] + async fn serves_web_index_same_origin_with_csp() { + let state = state(); + + let response = request(state, Method::GET, "/", json!({}), &[]).await; + let (status, body, headers) = response_bytes(response).await; + + assert_eq!(status, StatusCode::OK); + assert!(std::str::from_utf8(&body).unwrap().contains("id=\"root\"")); + assert_eq!( + headers.get(CONTENT_TYPE).unwrap(), + "text/html; charset=utf-8" + ); + assert!(headers.contains_key("content-security-policy")); + assert_eq!(headers.get("x-content-type-options").unwrap(), "nosniff"); + } + + #[tokio::test] + async fn serves_static_assets_with_mime_types() { + let state = state(); + + let response = request(state, Method::GET, "/assets/app.js", json!({}), &[]).await; + let (status, body, headers) = response_bytes(response).await; + + assert_eq!(status, StatusCode::OK); + assert_eq!(std::str::from_utf8(&body).unwrap(), "console.log('idea');"); + assert_eq!( + headers.get(CONTENT_TYPE).unwrap(), + "text/javascript; charset=utf-8" + ); + } + + #[tokio::test] + async fn serves_spa_fallback_for_non_api_routes_without_extension() { + let state = state(); + + let response = request(state, Method::GET, "/projects/abc", json!({}), &[]).await; + let (status, body, headers) = response_bytes(response).await; + + assert_eq!(status, StatusCode::OK); + assert!(std::str::from_utf8(&body).unwrap().contains("id=\"root\"")); + assert!(headers.contains_key("content-security-policy")); + } + + #[tokio::test] + async fn missing_asset_and_api_routes_do_not_fallback_to_spa() { + let state = state(); + let cookie = pair_and_cookie(Arc::clone(&state)).await; + + let missing_asset = request( + Arc::clone(&state), + Method::GET, + "/assets/missing.js", + json!({}), + &[], + ) + .await; + let (asset_status, asset_body, _) = response_json(missing_asset).await; + assert_eq!(asset_status, StatusCode::NOT_FOUND); + assert_eq!(asset_body["code"], "NOT_FOUND"); + + let api_unknown = request( + state, + Method::GET, + "/api/unknown", + json!({}), + &[("cookie", &cookie)], + ) + .await; + let (api_status, api_body, _) = response_json(api_unknown).await; + assert_eq!(api_status, StatusCode::NOT_FOUND); + assert_eq!(api_body["code"], "NOT_FOUND"); + } + + #[test] + fn static_route_rejects_traversal_and_dotfiles() { + let root = PathBuf::from("/tmp/web"); + + assert!(static_route(&root, "/../secret").is_none()); + assert!(static_route(&root, "/%2e%2e/secret").is_none()); + assert!(static_route(&root, "/.env").is_none()); + assert_eq!( + static_route(&root, "/dashboard").unwrap(), + StaticRoute { + candidate: root.join("dashboard"), + spa_fallback: true + } + ); + assert_eq!( + static_route(&root, "/assets/app.js").unwrap(), + StaticRoute { + candidate: root.join("assets").join("app.js"), + spa_fallback: false + } + ); + } + + #[test] + fn explicit_web_root_requires_index_html() { + let missing = std::env::temp_dir().join(format!("idea-empty-web-{}", Uuid::new_v4())); + std::fs::create_dir_all(&missing).unwrap(); + + assert!(validate_web_root(missing, "--web-root").is_err()); + assert!(validate_web_root(create_web_root(), "--web-root").is_ok()); + } + + #[tokio::test] + async fn non_allowed_origin_is_refused() { + let state = state(); + let mut req = Request::builder() + .method(Method::POST) + .uri("/api/pair") + .header(ORIGIN, "https://evil.example") + .header(CONTENT_TYPE, "application/json") + .body(Full::new(Bytes::from_static(br#"{"code":"PAIR1234"}"#))) + .unwrap(); + *req.headers_mut().get_mut(CONTENT_TYPE).unwrap() = + HeaderValue::from_static("application/json"); + + let response = handle_request(req, state).await; + let (status, body, _) = response_json(response).await; + + assert_eq!(status, StatusCode::FORBIDDEN); + assert_eq!(body["code"], "FORBIDDEN"); + } + + #[tokio::test] + async fn unknown_allowlisted_command_returns_unknown_command() { + let state = state(); + let cookie = pair_and_cookie(Arc::clone(&state)).await; + + let response = request( + state, + Method::POST, + "/api/invoke", + json!({ "command": "debug_dump", "args": {} }), + &[("cookie", &cookie)], + ) + .await; + let (status, body, _) = response_json(response).await; + + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!(body["code"], "UNKNOWN_COMMAND"); + } + + #[tokio::test] + async fn authorized_health_invoke_returns_health_report() { + let state = state(); + let cookie = pair_and_cookie(Arc::clone(&state)).await; + + let response = request( + state, + Method::POST, + "/api/invoke", + json!({ "command": "health", "args": { "request": { "note": "hi" } } }), + &[("cookie", &cookie)], + ) + .await; + let (status, body, _) = response_json(response).await; + + assert_eq!(status, StatusCode::OK); + assert_eq!(body["alive"], true); + assert_eq!(body["note"], "hi"); + } + + #[tokio::test] + async fn authorized_list_projects_returns_tauri_project_list_contract() { + let state = state(); + let project_id = create_project_for_test(&state, "Web Read").await; + let cookie = pair_and_cookie(Arc::clone(&state)).await; + + let response = request( + state, + Method::POST, + "/api/invoke", + json!({ "command": "list_projects", "args": {} }), + &[("cookie", &cookie)], + ) + .await; + let (status, body, _) = response_json(response).await; + + assert_eq!(status, StatusCode::OK); + let projects = body + .as_array() + .expect("ProjectListDto is a transparent array"); + let project = projects + .iter() + .find(|project| project["id"] == project_id) + .expect("created project is listed"); + assert_eq!(project["name"], "Web Read"); + assert!(project["root"] + .as_str() + .is_some_and(|root| root.contains("idea-server-project-"))); + } + + #[tokio::test] + async fn authorized_open_project_returns_readonly_project_dto() { + let state = state(); + let project_id = create_project_for_test(&state, "Readonly Open").await; + let cookie = pair_and_cookie(Arc::clone(&state)).await; + + let response = request( + state, + Method::POST, + "/api/invoke", + json!({ "command": "open_project", "args": { "projectId": project_id } }), + &[("cookie", &cookie)], + ) + .await; + let (status, body, _) = response_json(response).await; + + assert_eq!(status, StatusCode::OK); + assert_eq!(body["id"], project_id); + assert_eq!(body["name"], "Readonly Open"); + assert!(body["root"].is_string()); + } + + #[tokio::test] + async fn authorized_get_project_work_state_returns_tauri_contract() { + let state = state(); + let project_id = create_project_for_test(&state, "Readonly Work").await; + let cookie = pair_and_cookie(Arc::clone(&state)).await; + + let response = request( + state, + Method::POST, + "/api/invoke", + json!({ "command": "get_project_work_state", "args": { "projectId": project_id } }), + &[("cookie", &cookie)], + ) + .await; + let (status, body, _) = response_json(response).await; + + assert_eq!(status, StatusCode::OK); + assert!(body["agents"].as_array().is_some()); + assert!(body["conversations"].as_array().is_some()); + } + + #[tokio::test] + async fn authorized_list_background_tasks_returns_tauri_contract() { + let state = state(); + let project_id = create_project_for_test(&state, "Background Snapshot").await; + let (task_id, owner) = + create_background_task_for_test(&state, &project_id, "web background").await; + let cookie = pair_and_cookie(Arc::clone(&state)).await; + + let response = request( + state, + Method::POST, + "/api/invoke", + json!({ + "command": "list_background_tasks", + "args": { "projectId": project_id, "agentId": owner.to_string() } + }), + &[("cookie", &cookie)], + ) + .await; + let (status, body, _) = response_json(response).await; + + assert_eq!(status, StatusCode::OK); + let tasks = body.as_array().expect("background task list is an array"); + assert_eq!(tasks.len(), 1); + assert_eq!(tasks[0]["taskId"], task_id.to_string()); + assert_eq!(tasks[0]["ownerAgentId"], owner.to_string()); + assert_eq!(tasks[0]["projectId"], project_id); + assert_eq!(tasks[0]["kind"], "command"); + assert_eq!(tasks[0]["state"], "queued"); + } + + #[tokio::test] + async fn background_actions_are_allowlisted_with_auth_gate() { + let state = state(); + let cookie = pair_and_cookie(Arc::clone(&state)).await; + let bad_task_id = "not-a-task-id"; + + for command in ["cancel_background_task", "retry_background_task"] { + let response = request( + Arc::clone(&state), + Method::POST, + "/api/invoke", + json!({ "command": command, "args": { "taskId": bad_task_id } }), + &[("cookie", &cookie)], + ) + .await; + let (status, body, _) = response_json(response).await; + + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!( + body["code"], "INVALID", + "{command} must be an explicit action, not UNKNOWN_COMMAND" + ); + } + } + + #[tokio::test] + async fn mutation_and_pty_commands_stay_out_of_readonly_allowlist() { + let state = state(); + let cookie = pair_and_cookie(Arc::clone(&state)).await; + + for command in ["create_project", "open_terminal", "launch_agent"] { + let response = request( + Arc::clone(&state), + Method::POST, + "/api/invoke", + json!({ "command": command, "args": {} }), + &[("cookie", &cookie)], + ) + .await; + let (status, body, _) = response_json(response).await; + + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!( + body["code"], "UNKNOWN_COMMAND", + "{command} must stay blocked" + ); + } + } + + #[tokio::test] + async fn invalid_project_id_is_mapped_to_error_dto() { + let state = state(); + let cookie = pair_and_cookie(Arc::clone(&state)).await; + + let response = request( + state, + Method::POST, + "/api/invoke", + json!({ "command": "open_project", "args": { "projectId": "nope" } }), + &[("cookie", &cookie)], + ) + .await; + let (status, body, _) = response_json(response).await; + + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!(body["code"], "INVALID"); + } + + #[test] + fn direct_project_id_parser_still_rejects_invalid_ids() { + assert!(parse_project_id("not-a-uuid").is_err()); + } +}