Files
IdeA/crates/app-tauri/src/dto.rs
Blomios 307ae71857 feat: add main features
Agents for developpement added + frontend add + backend added. Git viewer created + agent and template creator + layout and project creator
2026-06-06 01:27:01 +02:00

1333 lines
39 KiB
Rust

//! 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::{
AppError, CreateProjectInput, CreateProjectOutput, GitGraphOutput, HealthInput, HealthReport,
LayoutKind, ListProjectsOutput, OpenProjectOutput,
};
use domain::{Project, ProjectId};
/// 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<String>,
}
impl From<HealthRequestDto> 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<String>,
}
impl From<HealthReport> 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<AppError> 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<Project> 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<domain::RemoteRef>,
/// Optional default profile id.
#[serde(default)]
pub default_profile_id: Option<String>,
}
impl From<CreateProjectRequestDto> 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<CreateProjectOutput> for ProjectDto {
fn from(out: CreateProjectOutput) -> Self {
out.project.into()
}
}
impl From<OpenProjectOutput> 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<ProjectId, ErrorDto> {
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<ProjectDto>);
impl From<ListProjectsOutput> 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<String>,
/// Optional arguments for the command.
#[serde(default)]
pub args: Vec<String>,
}
impl From<OpenTerminalRequestDto> 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,
}
impl From<OpenTerminalOutput> 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,
}
}
}
/// 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<u8>,
}
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<WriteToTerminalInput, ErrorDto> {
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<ResizeTerminalInput, ErrorDto> {
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<i32>,
}
impl From<CloseTerminalOutput> 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<CloseTerminalInput, ErrorDto> {
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<SessionId, ErrorDto> {
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,
};
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<LoadLayoutOutput> for LayoutDto {
fn from(out: LoadLayoutOutput) -> Self {
Self(out.layout)
}
}
impl From<MutateLayoutOutput> 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<f32>,
},
/// 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<String>,
},
/// 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<String>,
},
}
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<LayoutOperation, ErrorDto> {
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()?,
},
})
}
}
/// 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<NodeId, ErrorDto> {
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<LayoutId, ErrorDto> {
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<LayoutInfo> 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<LayoutInfoDto>,
/// The id of the currently active layout.
pub active_id: String,
}
impl From<ListLayoutsOutput> 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<CreateLayoutOutput> 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<DeleteLayoutOutput> 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<String>,
}
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<LayoutKind, ErrorDto> {
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,
}
/// 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::{
ConfigureProfilesInput, ConfigureProfilesOutput, DeleteProfileInput, DetectProfilesInput,
DetectProfilesOutput, FirstRunStateOutput, ListProfilesOutput, ProfileAvailability,
ReferenceProfilesOutput, SaveProfileInput, SaveProfileOutput,
};
use domain::profile::AgentProfile;
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<ProfileDto>);
impl From<Vec<AgentProfile>> for ProfileListDto {
fn from(v: Vec<AgentProfile>) -> Self {
Self(v.into_iter().map(ProfileDto).collect())
}
}
impl From<ListProfilesOutput> for ProfileListDto {
fn from(out: ListProfilesOutput) -> Self {
out.profiles.into()
}
}
impl From<ReferenceProfilesOutput> for ProfileListDto {
fn from(out: ReferenceProfilesOutput) -> Self {
out.profiles.into()
}
}
impl From<SaveProfileOutput> for ProfileDto {
fn from(out: SaveProfileOutput) -> 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<AgentProfile>,
}
impl From<DetectProfilesRequestDto> 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<ProfileAvailability> 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<ProfileAvailabilityDto>);
impl From<DetectProfilesOutput> 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<SaveProfileRequestDto> for SaveProfileInput {
fn from(dto: SaveProfileRequestDto) -> Self {
Self {
profile: dto.profile,
}
}
}
/// 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<AgentProfile>,
}
impl From<ConfigureProfilesRequestDto> for ConfigureProfilesInput {
fn from(dto: ConfigureProfilesRequestDto) -> Self {
Self {
profiles: dto.profiles,
}
}
}
impl From<ConfigureProfilesOutput> 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<AgentProfile>,
}
impl From<FirstRunStateOutput> for FirstRunStateDto {
fn from(out: FirstRunStateOutput) -> Self {
Self {
is_first_run: out.is_first_run,
reference_profiles: out.reference_profiles,
}
}
}
/// 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<DeleteProfileInput, ErrorDto> {
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<ProfileId, ErrorDto> {
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::{
CreateAgentOutput, LaunchAgentOutput, ListAgentsOutput, ReadAgentContextOutput,
};
use domain::Agent;
/// 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<AgentDto>);
impl From<ListAgentsOutput> for AgentListDto {
fn from(out: ListAgentsOutput) -> Self {
Self(out.agents.into_iter().map(AgentDto).collect())
}
}
impl From<CreateAgentOutput> 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<String>,
}
/// 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<ReadAgentContextOutput> 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,
}
/// 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,
}
impl From<LaunchAgentOutput> for TerminalSessionDto {
fn from(out: LaunchAgentOutput) -> 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,
}
}
}
/// 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<AgentId, ErrorDto> {
uuid::Uuid::parse_str(raw)
.map(AgentId::from_uuid)
.map_err(|_| ErrorDto {
code: "INVALID".to_owned(),
message: format!("invalid agent id: {raw}"),
})
}
// ---------------------------------------------------------------------------
// 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<TemplateDto>);
impl From<ListTemplatesOutput> for TemplateListDto {
fn from(out: ListTemplatesOutput) -> Self {
Self(out.templates.into_iter().map(TemplateDto).collect())
}
}
impl From<CreateTemplateOutput> for TemplateDto {
fn from(out: CreateTemplateOutput) -> Self {
Self(out.template)
}
}
impl From<UpdateTemplateOutput> 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<CreateTemplateInput, ErrorDto> {
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<UpdateTemplateInput, ErrorDto> {
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<TemplateId, ErrorDto> {
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<String>,
/// 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<CreateAgentFromTemplateInput, ErrorDto> {
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<AgentDrift> 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<AgentDriftDto>);
impl From<DetectAgentDriftOutput> 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<u64>,
}
impl From<SyncAgentWithTemplateOutput> 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<GitFileStatus> 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<GitFileStatusDto>);
impl From<GitStatusOutput> 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<GitCommitInfo> for GitCommitDto {
fn from(c: GitCommitInfo) -> Self {
Self {
hash: c.hash,
summary: c.summary,
}
}
}
impl From<GitCommitOutput> 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<GitCommitDto>);
impl From<GitLogOutput> 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<String>,
/// The current branch (`null` when detached or unborn).
pub current: Option<String>,
}
impl From<GitBranchesOutput> 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<String>,
/// Ref labels pointing at this commit (e.g. `"main"`, `"tag: v1.0"`).
pub refs: Vec<String>,
/// Author name.
pub author: String,
/// Author timestamp in Unix seconds.
pub timestamp: i64,
}
impl From<GraphCommit> 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<GraphCommitDto>);
impl From<GitGraphOutput> 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<MoveTabToNewWindowOutput> 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<TabId, ErrorDto> {
uuid::Uuid::parse_str(raw)
.map(TabId::from_uuid)
.map_err(|_| ErrorDto {
code: "INVALID".to_owned(),
message: format!("invalid tab id: {raw}"),
})
}