2869 lines
93 KiB
Rust
2869 lines
93 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::{
|
|
AgentTicketState, AppError, AttachLiveAgentOutput, 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<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,
|
|
/// 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<String>,
|
|
/// **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<String>,
|
|
/// 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<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,
|
|
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<u8>,
|
|
}
|
|
|
|
/// 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, 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<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>,
|
|
},
|
|
/// 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<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()?,
|
|
},
|
|
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<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,
|
|
}
|
|
|
|
/// 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<SetActiveLayoutOutput> 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::{
|
|
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,
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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<EmbedderProfileDto>);
|
|
|
|
impl From<Vec<EmbedderProfile>> for EmbedderProfileListDto {
|
|
fn from(v: Vec<EmbedderProfile>) -> Self {
|
|
Self(v.into_iter().map(EmbedderProfileDto).collect())
|
|
}
|
|
}
|
|
|
|
impl From<ListEmbedderProfilesOutput> for EmbedderProfileListDto {
|
|
fn from(out: ListEmbedderProfilesOutput) -> Self {
|
|
out.profiles.into()
|
|
}
|
|
}
|
|
|
|
impl From<SaveEmbedderProfileOutput> 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<SaveEmbedderProfileRequestDto> 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<OnnxModelView> 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<OnnxModelInfoDto>,
|
|
/// 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<String>,
|
|
/// 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<EmbedderEnginesView> 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<DismissChoiceDto> 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<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::{
|
|
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<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,
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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<PermissionSet>,
|
|
}
|
|
|
|
/// 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<PermissionSet>,
|
|
}
|
|
|
|
/// 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<String>,
|
|
/// 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<String>,
|
|
}
|
|
|
|
impl From<LaunchAgentOutput> 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<TerminalSessionDto>,
|
|
}
|
|
|
|
impl From<ChangeAgentProfileOutput> for ChangeAgentProfileDto {
|
|
fn from(out: ChangeAgentProfileOutput) -> Self {
|
|
Self {
|
|
agent: AgentDto(out.agent),
|
|
relaunched_session: out.relaunched.map(TerminalSessionDto::from),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<TerminalSession> 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"`), so
|
|
/// the front branches without positional parsing. `Final` is the **deterministic
|
|
/// terminal** chunk of a turn — after it the turn is frozen and the stream ends.
|
|
/// `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,
|
|
},
|
|
}
|
|
|
|
/// 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<ReplyChunk>,
|
|
}
|
|
|
|
/// 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<String>,
|
|
/// A best-effort cumulative token count. Absent when no usage info exists.
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub token_count: Option<u64>,
|
|
}
|
|
|
|
impl From<InspectConversationOutput> 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<LiveAgentDto>);
|
|
|
|
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<LiveSessionSnapshot>) -> 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<LiveSessionKind> 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<TicketWorkStatus> 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<TicketWorkSource> 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<AgentTicketState> 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 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<LiveWorkSessionDto>,
|
|
/// Current mediated-input busy state.
|
|
pub busy: AgentBusyState,
|
|
/// Pending/in-progress delegation tickets, in FIFO order.
|
|
pub tickets: Vec<AgentTicketStateDto>,
|
|
}
|
|
|
|
/// 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<ConversationPreviewStatus> 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<ConversationTurnWorkPreview> 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<String>,
|
|
/// Bounded excerpt of the handoff summary, when present.
|
|
pub summary_preview: Option<String>,
|
|
/// 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<String>,
|
|
/// Bounded, recent turns from the log fallback.
|
|
pub recent_turns: Vec<ConversationTurnWorkPreviewDto>,
|
|
}
|
|
|
|
impl From<ConversationWorkSummary> 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<AgentWorkStateDto>,
|
|
/// Best-effort summaries of the conversations referenced by the tickets,
|
|
/// joined frontend-side via `tickets[].conversationId`.
|
|
pub conversations: Vec<ConversationWorkSummaryDto>,
|
|
}
|
|
|
|
impl From<ProjectWorkState> 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(),
|
|
})
|
|
.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<AttachLiveAgentOutput> 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<StopLiveAgentOutput> 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<AgentId, ErrorDto> {
|
|
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<domain::TicketId, ErrorDto> {
|
|
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<String>,
|
|
/// 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<ResumableAgent> 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<ResumableAgentDto>,
|
|
}
|
|
|
|
impl From<ListResumableAgentsOutput> 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<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}"),
|
|
})
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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<SkillDto>);
|
|
|
|
impl From<ListSkillsOutput> for SkillListDto {
|
|
fn from(out: ListSkillsOutput) -> Self {
|
|
Self(out.skills.into_iter().map(SkillDto).collect())
|
|
}
|
|
}
|
|
|
|
impl From<CreateSkillOutput> for SkillDto {
|
|
fn from(out: CreateSkillOutput) -> Self {
|
|
Self(out.skill)
|
|
}
|
|
}
|
|
|
|
impl From<UpdateSkillOutput> 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<SkillId, ErrorDto> {
|
|
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<Memory> 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<CreateMemoryOutput> for MemoryDto {
|
|
fn from(out: CreateMemoryOutput) -> Self {
|
|
Self::from(out.memory)
|
|
}
|
|
}
|
|
|
|
impl From<UpdateMemoryOutput> for MemoryDto {
|
|
fn from(out: UpdateMemoryOutput) -> Self {
|
|
Self::from(out.memory)
|
|
}
|
|
}
|
|
|
|
impl From<GetMemoryOutput> 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<MemoryDto>);
|
|
|
|
impl From<ListMemoriesOutput> 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<MemoryIndexEntry> 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<MemoryIndexEntryDto>);
|
|
|
|
impl From<ReadMemoryIndexOutput> for MemoryIndexDto {
|
|
fn from(out: ReadMemoryIndexOutput) -> Self {
|
|
Self(
|
|
out.entries
|
|
.into_iter()
|
|
.map(MemoryIndexEntryDto::from)
|
|
.collect(),
|
|
)
|
|
}
|
|
}
|
|
|
|
impl From<RecallMemoryOutput> 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<String>);
|
|
|
|
impl From<ResolveMemoryLinksOutput> 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, ErrorDto> {
|
|
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<String>,
|
|
/// Pagination direction (`"forward"` or `"backward"`); defaults to backward.
|
|
#[serde(default)]
|
|
pub direction: Option<String>,
|
|
/// Requested page size (omit/`0` ⇒ default; clamped to `[1, 200]`).
|
|
#[serde(default)]
|
|
pub limit: Option<usize>,
|
|
}
|
|
|
|
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<TurnSource> 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<TurnView> 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<TurnViewDto>,
|
|
/// 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<String>,
|
|
}
|
|
|
|
impl From<TurnPage> 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());
|
|
}
|
|
}
|