Implémente la vue chat structurée par cellule agent (toggle TUI/CLI custom, préférence persistée `preferred_view`, reattach live, composer + pièces jointes) avec le socle backend AgentSession/ChatBridge (UserPrompt, cancel_current_turn, routage interrupt_agent, commande cancel_agent_chat). Corrige le bug bloquant relevé par QA : le bouton Cancel de CustomAgentChatView interrompait tout le tour via closeAgentChat au lieu de n'annuler que le tour courant via cancelAgentChat, ce qui tuait la session contrairement au contrat produit validé. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
5413 lines
179 KiB
Rust
5413 lines
179 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 serde_json::Value;
|
|
|
|
use application::{
|
|
AgentBackgroundTaskState, AgentTicketState, AppError, AppExitWorkGuardDetail,
|
|
AppExitWorkGuardState, AttachLiveAgentOutput, BackgroundTaskKindLabel,
|
|
ConversationPreviewStatus, ConversationTurnWorkPreview, ConversationWorkSummary,
|
|
CreateProjectInput, CreateProjectOutput, GitGraphOutput, HealthInput, HealthReport, LayoutKind,
|
|
ListProjectsOutput, LiveSessionKind, LiveSessionSnapshot, OpenProjectOutput, ProjectWorkState,
|
|
StopLiveAgentOutput, TicketWorkSource, TicketWorkStatus, TurnPage, TurnSource, TurnView,
|
|
};
|
|
use domain::{
|
|
AgentBusyState, PageCursor, PageDirection, Project, ProjectId, ProjectSystemPermissions,
|
|
ResolvedAgentSystemPermissions, SystemPermissionSet, TurnRole,
|
|
};
|
|
|
|
pub use crate::ticket_dto::*;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Plugins (#43)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Plugin contribution summary DTO.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct PluginContributionSummaryDto {
|
|
/// Top-level menus count.
|
|
pub top_level_menus: usize,
|
|
/// Menu items count.
|
|
pub menu_items: usize,
|
|
/// Layouts count.
|
|
pub layouts: usize,
|
|
/// MCP servers count.
|
|
pub mcp_servers: usize,
|
|
}
|
|
|
|
impl From<application::PluginContributionSummary> for PluginContributionSummaryDto {
|
|
fn from(value: application::PluginContributionSummary) -> Self {
|
|
Self {
|
|
top_level_menus: value.top_level_menus,
|
|
menu_items: value.menu_items,
|
|
layouts: value.layouts,
|
|
mcp_servers: value.mcp_servers,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Admin plugin DTO.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct PluginAdminDto {
|
|
/// Plugin id.
|
|
pub id: String,
|
|
/// Display name.
|
|
pub display_name: String,
|
|
/// Publisher.
|
|
pub publisher: Option<String>,
|
|
/// Version.
|
|
pub version: String,
|
|
/// Description.
|
|
pub description: Option<String>,
|
|
/// Icon URL.
|
|
pub icon_url: Option<String>,
|
|
/// Source kind.
|
|
pub source_kind: String,
|
|
/// Source label.
|
|
pub source_label: Option<String>,
|
|
/// Lifecycle state.
|
|
pub lifecycle_state: domain::PluginLifecycleState,
|
|
/// Enabled flag.
|
|
pub enabled: bool,
|
|
/// Pending enable state.
|
|
pub pending_enable_state: Option<bool>,
|
|
/// Pending uninstall flag.
|
|
pub pending_uninstall: bool,
|
|
/// Restart required flag.
|
|
pub restart_required: bool,
|
|
/// Trust level.
|
|
pub trust_level: domain::PluginTrustLevel,
|
|
/// Contribution summary.
|
|
pub contribution_summary: PluginContributionSummaryDto,
|
|
/// Optional error.
|
|
pub error: Option<String>,
|
|
}
|
|
|
|
impl From<application::PluginAdmin> for PluginAdminDto {
|
|
fn from(value: application::PluginAdmin) -> Self {
|
|
Self {
|
|
id: value.id,
|
|
display_name: value.display_name,
|
|
publisher: value.publisher,
|
|
version: value.version,
|
|
description: value.description,
|
|
icon_url: value.icon_url,
|
|
source_kind: value.source_kind,
|
|
source_label: value.source_label,
|
|
lifecycle_state: value.lifecycle_state,
|
|
enabled: value.enabled,
|
|
pending_enable_state: value.pending_enable_state,
|
|
pending_uninstall: value.pending_uninstall,
|
|
restart_required: value.restart_required,
|
|
trust_level: value.trust_level,
|
|
contribution_summary: value.contribution_summary.into(),
|
|
error: value.error,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Review request DTO.
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct ReviewPluginPackageDto {
|
|
/// Source kind: `archive` or `directory`.
|
|
pub source_kind: String,
|
|
/// Local source path.
|
|
pub path: String,
|
|
}
|
|
|
|
/// Plugin review DTO.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct PluginReviewDto {
|
|
/// Manifest id.
|
|
pub id: String,
|
|
/// Display name.
|
|
pub display_name: String,
|
|
/// Publisher.
|
|
pub publisher: Option<String>,
|
|
/// Version.
|
|
pub version: String,
|
|
/// Description.
|
|
pub description: Option<String>,
|
|
/// Source kind.
|
|
pub source_kind: String,
|
|
/// Source label.
|
|
pub source_label: Option<String>,
|
|
/// Content hash.
|
|
pub content_hash: String,
|
|
/// Trust level.
|
|
pub trust_level: domain::PluginTrustLevel,
|
|
/// Summary.
|
|
pub contribution_summary: PluginContributionSummaryDto,
|
|
/// Manifest contributions.
|
|
pub contributes: domain::PluginContributionSet,
|
|
}
|
|
|
|
impl From<application::PluginReview> for PluginReviewDto {
|
|
fn from(value: application::PluginReview) -> Self {
|
|
Self {
|
|
id: value.manifest.id.as_str().to_owned(),
|
|
display_name: value.manifest.display_name,
|
|
publisher: value.manifest.publisher,
|
|
version: value.manifest.version.as_str().to_owned(),
|
|
description: value.manifest.description,
|
|
source_kind: value.source.kind().to_owned(),
|
|
source_label: Some(value.source.label().to_owned()),
|
|
content_hash: value.content_hash,
|
|
trust_level: value.trust_level,
|
|
contribution_summary: value.contribution_summary.into(),
|
|
contributes: value.manifest.contributes,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Plugin install result DTO.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct PluginInstallResultDto {
|
|
/// Installed plugin.
|
|
pub plugin: PluginAdminDto,
|
|
/// Review.
|
|
pub review: PluginReviewDto,
|
|
/// Restart required.
|
|
pub restart_required: bool,
|
|
}
|
|
|
|
impl From<application::PluginInstallResult> for PluginInstallResultDto {
|
|
fn from(value: application::PluginInstallResult) -> Self {
|
|
Self {
|
|
plugin: value.plugin.into(),
|
|
review: value.review.into(),
|
|
restart_required: value.restart_required,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Plugin uninstall result DTO.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct PluginUninstallResultDto {
|
|
/// Plugin id.
|
|
pub plugin_id: String,
|
|
/// Removal outcome.
|
|
pub removal_outcome: domain::RemovalOutcome,
|
|
/// Restart required.
|
|
pub restart_required: bool,
|
|
}
|
|
|
|
impl From<application::UninstallPluginResult> for PluginUninstallResultDto {
|
|
fn from(value: application::UninstallPluginResult) -> Self {
|
|
Self {
|
|
plugin_id: value.plugin_id,
|
|
removal_outcome: value.removal_outcome,
|
|
restart_required: value.restart_required,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Runtime catalog DTO.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct PluginRuntimeContributionCatalogDto {
|
|
/// Runtime plugins.
|
|
pub plugins: Vec<PluginRuntimePluginDto>,
|
|
}
|
|
|
|
/// Runtime plugin DTO.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct PluginRuntimePluginDto {
|
|
/// Plugin id.
|
|
pub id: String,
|
|
/// Display name.
|
|
pub display_name: String,
|
|
/// Publisher.
|
|
pub publisher: Option<String>,
|
|
/// Version.
|
|
pub version: String,
|
|
/// Bundle URL.
|
|
pub bundle_url: String,
|
|
/// Icon URL.
|
|
pub icon_url: Option<String>,
|
|
/// Content hash.
|
|
pub content_hash: String,
|
|
/// Public manifest capabilities.
|
|
pub capabilities: Vec<domain::PluginCapability>,
|
|
/// Manifest-declared activation scope.
|
|
pub activation_scope: domain::PluginActivationScope,
|
|
/// Contributions.
|
|
pub contributes: domain::PluginContributionSet,
|
|
}
|
|
|
|
impl From<application::PluginRuntimeCatalog> for PluginRuntimeContributionCatalogDto {
|
|
fn from(value: application::PluginRuntimeCatalog) -> Self {
|
|
Self {
|
|
plugins: value
|
|
.plugins
|
|
.into_iter()
|
|
.map(PluginRuntimePluginDto::from)
|
|
.collect(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<application::PluginRuntimePlugin> for PluginRuntimePluginDto {
|
|
fn from(value: application::PluginRuntimePlugin) -> Self {
|
|
Self {
|
|
id: value.id,
|
|
display_name: value.display_name,
|
|
publisher: value.publisher,
|
|
version: value.version,
|
|
bundle_url: value.bundle_url,
|
|
icon_url: value.icon_url,
|
|
content_hash: value.content_hash,
|
|
capabilities: value.capabilities,
|
|
activation_scope: value.activation_scope,
|
|
contributes: value.contributes,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Plugin workspace path request DTO.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct PluginWorkspacePathDto {
|
|
/// Project id.
|
|
pub project_id: String,
|
|
/// Relative path under the project root.
|
|
pub path: String,
|
|
}
|
|
|
|
impl From<PluginWorkspacePathDto> for application::PluginWorkspacePathInput {
|
|
fn from(value: PluginWorkspacePathDto) -> Self {
|
|
Self {
|
|
project_id: value.project_id,
|
|
path: value.path,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Plugin workspace text write request DTO.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct PluginWorkspaceWriteTextDto {
|
|
/// Project id.
|
|
pub project_id: String,
|
|
/// Relative path under the project root.
|
|
pub path: String,
|
|
/// UTF-8 content.
|
|
pub content: String,
|
|
}
|
|
|
|
impl From<PluginWorkspaceWriteTextDto> for application::PluginWorkspaceWriteTextInput {
|
|
fn from(value: PluginWorkspaceWriteTextDto) -> Self {
|
|
Self {
|
|
project_id: value.project_id,
|
|
path: value.path,
|
|
content: value.content,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Plugin workspace binary write request DTO.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct PluginWorkspaceWriteBinaryDto {
|
|
/// Project id.
|
|
pub project_id: String,
|
|
/// Relative path under the project root.
|
|
pub path: String,
|
|
/// Raw bytes.
|
|
pub bytes: Vec<u8>,
|
|
}
|
|
|
|
impl From<PluginWorkspaceWriteBinaryDto> for application::PluginWorkspaceWriteBinaryInput {
|
|
fn from(value: PluginWorkspaceWriteBinaryDto) -> Self {
|
|
Self {
|
|
project_id: value.project_id,
|
|
path: value.path,
|
|
bytes: value.bytes,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Plugin-owned storage read/delete request DTO.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct PluginStorageGetDto {
|
|
/// Plugin id owning the value.
|
|
pub plugin_id: String,
|
|
/// Plugin-owned key.
|
|
pub key: String,
|
|
}
|
|
|
|
impl From<PluginStorageGetDto> for application::PluginStorageGetInput {
|
|
fn from(value: PluginStorageGetDto) -> Self {
|
|
Self {
|
|
plugin_id: value.plugin_id,
|
|
key: value.key,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Plugin-owned storage write request DTO.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct PluginStorageSetDto {
|
|
/// Plugin id owning the value.
|
|
pub plugin_id: String,
|
|
/// Plugin-owned key.
|
|
pub key: String,
|
|
/// JSON value to persist.
|
|
pub value: Value,
|
|
}
|
|
|
|
impl From<PluginStorageSetDto> for application::PluginStorageSetInput {
|
|
fn from(value: PluginStorageSetDto) -> Self {
|
|
Self {
|
|
plugin_id: value.plugin_id,
|
|
key: value.key,
|
|
value: value.value,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Plugin structured config document read request DTO.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct PluginConfigDocumentReadDto {
|
|
/// Project id.
|
|
pub project_id: String,
|
|
/// Relative path under the project root.
|
|
pub path: String,
|
|
/// Optional explicit format. Omitted means inferred from extension.
|
|
#[serde(default)]
|
|
pub format: Option<String>,
|
|
}
|
|
|
|
impl From<PluginConfigDocumentReadDto> for application::PluginConfigDocumentReadInput {
|
|
fn from(value: PluginConfigDocumentReadDto) -> Self {
|
|
Self {
|
|
project_id: value.project_id,
|
|
path: value.path,
|
|
format: value.format,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Plugin structured config document update request DTO.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct PluginConfigDocumentUpdateDto {
|
|
/// Project id.
|
|
pub project_id: String,
|
|
/// Relative path under the project root.
|
|
pub path: String,
|
|
/// Optional explicit format. Omitted means inferred from extension.
|
|
#[serde(default)]
|
|
pub format: Option<String>,
|
|
/// Update mode: `mergePatch` (default) or `replace`.
|
|
#[serde(default)]
|
|
pub mode: Option<String>,
|
|
/// JSON replacement or merge patch.
|
|
pub value: Value,
|
|
}
|
|
|
|
impl From<PluginConfigDocumentUpdateDto> for application::PluginConfigDocumentUpdateInput {
|
|
fn from(value: PluginConfigDocumentUpdateDto) -> Self {
|
|
Self {
|
|
project_id: value.project_id,
|
|
path: value.path,
|
|
format: value.format,
|
|
mode: value.mode,
|
|
value: value.value,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Plugin project structure query request DTO.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct PluginProjectStructureQueryDto {
|
|
/// Project id.
|
|
pub project_id: String,
|
|
/// Optional relative root path.
|
|
#[serde(default)]
|
|
pub path: Option<String>,
|
|
/// Optional traversal depth.
|
|
#[serde(default)]
|
|
pub max_depth: Option<u8>,
|
|
/// Optional entry cap.
|
|
#[serde(default)]
|
|
pub max_entries: Option<usize>,
|
|
}
|
|
|
|
impl From<PluginProjectStructureQueryDto> for application::QueryProjectStructureInput {
|
|
fn from(value: PluginProjectStructureQueryDto) -> Self {
|
|
Self {
|
|
project_id: value.project_id,
|
|
path: value.path,
|
|
max_depth: value.max_depth,
|
|
max_entries: value.max_entries,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Public plugin command-task launch request DTO.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct PluginRunCommandDto {
|
|
/// Owning project id.
|
|
pub project_id: String,
|
|
/// Agent id used for Work correlation and completion wake delivery.
|
|
pub owner_agent_id: String,
|
|
/// Human-facing task label.
|
|
pub label: String,
|
|
/// Executable to run.
|
|
pub command: String,
|
|
/// Arguments passed without shell parsing.
|
|
#[serde(default)]
|
|
pub args: Vec<String>,
|
|
/// Relative working directory under project root. Empty/omitted means root.
|
|
#[serde(default)]
|
|
pub cwd: Option<String>,
|
|
/// Extra environment variables.
|
|
#[serde(default)]
|
|
pub env: Vec<(String, String)>,
|
|
/// When true, completion is only recorded; otherwise the owner is woken.
|
|
#[serde(default)]
|
|
pub record_only: bool,
|
|
/// Optional absolute deadline, epoch milliseconds.
|
|
#[serde(default)]
|
|
pub deadline_ms: Option<u64>,
|
|
}
|
|
|
|
impl From<PluginRunCommandDto> for application::PluginRunCommandInput {
|
|
fn from(value: PluginRunCommandDto) -> Self {
|
|
Self {
|
|
project_id: value.project_id,
|
|
owner_agent_id: value.owner_agent_id,
|
|
label: value.label,
|
|
command: value.command,
|
|
args: value.args,
|
|
cwd: value.cwd,
|
|
env: value.env,
|
|
record_only: value.record_only,
|
|
deadline_ms: value.deadline_ms,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Public plugin task status request DTO.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct PluginTaskStatusDto {
|
|
/// Task id to read.
|
|
pub task_id: String,
|
|
}
|
|
|
|
impl From<PluginTaskStatusDto> for application::PluginTaskStatusInput {
|
|
fn from(value: PluginTaskStatusDto) -> Self {
|
|
Self {
|
|
task_id: value.task_id,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Public plugin command-task status/output DTO.
|
|
pub type PluginTaskDto = BackgroundTaskDto;
|
|
|
|
/// Public plugin external-toolchain diagnostic request DTO.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct PluginToolchainDiagnosticRequestDto {
|
|
/// Owning project id.
|
|
pub project_id: String,
|
|
/// Relative working directory under project root.
|
|
#[serde(default)]
|
|
pub cwd: Option<String>,
|
|
/// Executable probes to run.
|
|
#[serde(default)]
|
|
pub tools: Vec<PluginToolRequirementDto>,
|
|
/// Environment variable prerequisites.
|
|
#[serde(default)]
|
|
pub env: Vec<PluginEnvRequirementDto>,
|
|
/// Workspace file prerequisites.
|
|
#[serde(default)]
|
|
pub files: Vec<PluginFileRequirementDto>,
|
|
}
|
|
|
|
impl From<PluginToolchainDiagnosticRequestDto> for application::PluginToolchainDiagnosticInput {
|
|
fn from(value: PluginToolchainDiagnosticRequestDto) -> Self {
|
|
Self {
|
|
project_id: value.project_id,
|
|
cwd: value.cwd,
|
|
tools: value.tools.into_iter().map(Into::into).collect(),
|
|
env: value.env.into_iter().map(Into::into).collect(),
|
|
files: value.files.into_iter().map(Into::into).collect(),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Public plugin executable probe DTO.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct PluginToolRequirementDto {
|
|
/// Stable requirement id.
|
|
pub id: String,
|
|
/// Executable name or path.
|
|
pub executable: String,
|
|
/// Version/diagnostic arguments.
|
|
#[serde(default)]
|
|
pub version_args: Vec<String>,
|
|
/// Whether this probe is required.
|
|
#[serde(default)]
|
|
pub required: bool,
|
|
/// Extra environment variables for the probe.
|
|
#[serde(default)]
|
|
pub env: Vec<(String, String)>,
|
|
}
|
|
|
|
impl From<PluginToolRequirementDto> for application::PluginToolRequirement {
|
|
fn from(value: PluginToolRequirementDto) -> Self {
|
|
Self {
|
|
id: value.id,
|
|
executable: value.executable,
|
|
version_args: value.version_args,
|
|
required: value.required,
|
|
env: value.env,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Public plugin environment prerequisite DTO.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct PluginEnvRequirementDto {
|
|
/// Environment variable name.
|
|
pub name: String,
|
|
/// Whether this variable is required.
|
|
#[serde(default)]
|
|
pub required: bool,
|
|
/// Optional exact expected value.
|
|
#[serde(default)]
|
|
pub equals: Option<String>,
|
|
}
|
|
|
|
impl From<PluginEnvRequirementDto> for application::PluginEnvRequirement {
|
|
fn from(value: PluginEnvRequirementDto) -> Self {
|
|
Self {
|
|
name: value.name,
|
|
required: value.required,
|
|
equals: value.equals,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Public plugin workspace file prerequisite DTO.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct PluginFileRequirementDto {
|
|
/// Relative path under project root.
|
|
pub path: String,
|
|
/// Whether this path is required.
|
|
#[serde(default)]
|
|
pub required: bool,
|
|
/// Expected kind: `file`, `directory`, or `any`.
|
|
#[serde(default)]
|
|
pub kind: Option<String>,
|
|
}
|
|
|
|
impl From<PluginFileRequirementDto> for application::PluginFileRequirement {
|
|
fn from(value: PluginFileRequirementDto) -> Self {
|
|
Self {
|
|
path: value.path,
|
|
required: value.required,
|
|
kind: value.kind,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Public plugin external-toolchain diagnostic output DTO.
|
|
pub type PluginToolchainDiagnosticDto = application::PluginToolchainDiagnostic;
|
|
|
|
/// Public plugin event subscription request DTO.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct PluginEventSubscribeDto {
|
|
/// Project id to observe.
|
|
pub project_id: String,
|
|
/// Public event types to retain. Empty means all supported types.
|
|
#[serde(default)]
|
|
pub event_types: Vec<String>,
|
|
/// Per-subscription retained event capacity.
|
|
#[serde(default)]
|
|
pub capacity: Option<usize>,
|
|
}
|
|
|
|
impl From<PluginEventSubscribeDto> for application::PluginEventSubscribeInput {
|
|
fn from(value: PluginEventSubscribeDto) -> Self {
|
|
Self {
|
|
project_id: value.project_id,
|
|
event_types: value.event_types,
|
|
capacity: value.capacity,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Public plugin event poll request DTO.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct PluginEventPollDto {
|
|
/// Subscription id returned by subscribe.
|
|
pub subscription_id: String,
|
|
/// Maximum number of events to drain.
|
|
#[serde(default)]
|
|
pub max_events: Option<usize>,
|
|
}
|
|
|
|
impl From<PluginEventPollDto> for application::PluginEventPollInput {
|
|
fn from(value: PluginEventPollDto) -> Self {
|
|
Self {
|
|
subscription_id: value.subscription_id,
|
|
max_events: value.max_events,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Public plugin event unsubscribe request DTO.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct PluginEventUnsubscribeDto {
|
|
/// Subscription id returned by subscribe.
|
|
pub subscription_id: String,
|
|
}
|
|
|
|
impl From<PluginEventUnsubscribeDto> for application::PluginEventUnsubscribeInput {
|
|
fn from(value: PluginEventUnsubscribeDto) -> Self {
|
|
Self {
|
|
subscription_id: value.subscription_id,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Public plugin event subscription output DTO.
|
|
pub type PluginEventSubscriptionDto = application::PluginEventSubscription;
|
|
/// Public plugin event poll output DTO.
|
|
pub type PluginEventBatchDto = application::PluginEventBatch;
|
|
|
|
/// Plugin workspace text file DTO.
|
|
pub type PluginWorkspaceTextFileDto = application::PluginWorkspaceTextFile;
|
|
/// Plugin workspace binary file DTO.
|
|
pub type PluginWorkspaceBinaryFileDto = application::PluginWorkspaceBinaryFile;
|
|
/// Plugin workspace directory listing DTO.
|
|
pub type PluginWorkspaceDirectoryListingDto = application::PluginWorkspaceDirectoryListing;
|
|
/// Plugin workspace stat DTO.
|
|
pub type PluginWorkspaceStatDto = application::PluginWorkspaceStat;
|
|
/// Plugin structured config document DTO.
|
|
pub type PluginConfigDocumentDto = application::PluginConfigDocument;
|
|
/// Plugin structured config document write result DTO.
|
|
pub type PluginConfigDocumentWriteResultDto = application::PluginConfigDocumentWriteResult;
|
|
/// Plugin project structure result DTO.
|
|
pub type PluginProjectStructureDto = application::ProjectStructureQuery;
|
|
|
|
/// 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(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl ErrorDto {
|
|
/// Builds a stable `INVALID` error.
|
|
#[must_use]
|
|
pub fn invalid(message: impl Into<String>) -> Self {
|
|
Self {
|
|
code: "INVALID".to_owned(),
|
|
message: message.into(),
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Projects (L2)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// A project as seen by the frontend (camelCase wire shape).
|
|
///
|
|
/// `remote` is the domain [`domain::RemoteRef`], which already serialises
|
|
/// camelCase + tagged (`kind`), so we embed it directly.
|
|
#[derive(Debug, Clone, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct ProjectDto {
|
|
/// Stable project id (UUID string).
|
|
pub id: String,
|
|
/// Display name.
|
|
pub name: String,
|
|
/// Absolute project root.
|
|
pub root: String,
|
|
/// Where the project lives (`{ "kind": "local" }`, `ssh`, `wsl`).
|
|
pub remote: domain::RemoteRef,
|
|
/// Creation timestamp, epoch milliseconds.
|
|
pub created_at: i64,
|
|
}
|
|
|
|
impl From<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>,
|
|
}
|
|
|
|
/// Response DTO for `attach_background_task`: the retained bytes to repaint for
|
|
/// a background command task before optional live bytes arrive on the channel.
|
|
#[derive(Debug, Clone, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct AttachBackgroundTaskResultDto {
|
|
/// The task that was attached (echoed back for the frontend).
|
|
pub task_id: String,
|
|
/// Recent output bytes: PTY scrollback for a live task, persisted tail for a
|
|
/// terminal task.
|
|
pub scrollback: Vec<u8>,
|
|
/// Whether a live PTY subscription was installed for subsequent output.
|
|
pub live: bool,
|
|
}
|
|
|
|
/// 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, PluginLayoutOrigin, SetActiveLayoutOutput,
|
|
};
|
|
use domain::{
|
|
AgentId, Direction, LayoutId, LayoutTree, NodeId, PluginId, PluginLayoutType, PreferredView,
|
|
};
|
|
|
|
/// 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>,
|
|
},
|
|
/// Persist the preferred live view for an agent leaf.
|
|
#[serde(rename_all = "camelCase")]
|
|
SetCellPreferredView {
|
|
/// Hosting leaf.
|
|
target: String,
|
|
/// Preferred live view.
|
|
preferred_view: PreferredView,
|
|
},
|
|
/// Persist opaque plugin layout state.
|
|
#[serde(rename_all = "camelCase")]
|
|
SetPluginLayoutState {
|
|
/// Custom plugin layout node.
|
|
target: String,
|
|
/// Opaque plugin-owned state.
|
|
#[serde(default)]
|
|
state: serde_json::Value,
|
|
},
|
|
}
|
|
|
|
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,
|
|
},
|
|
Self::SetCellPreferredView {
|
|
target,
|
|
preferred_view,
|
|
} => LayoutOperation::SetCellPreferredView {
|
|
target: parse_node_id(&target)?,
|
|
preferred_view,
|
|
},
|
|
Self::SetPluginLayoutState { target, state } => LayoutOperation::SetPluginLayoutState {
|
|
target: parse_node_id(&target)?,
|
|
state,
|
|
},
|
|
})
|
|
}
|
|
}
|
|
|
|
/// 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"`, `"gitGraph"` or `"plugin"`.
|
|
pub kind: String,
|
|
}
|
|
|
|
impl From<LayoutInfo> for LayoutInfoDto {
|
|
fn from(info: LayoutInfo) -> Self {
|
|
let kind = match info.kind {
|
|
LayoutKind::Terminal => "terminal",
|
|
LayoutKind::GitGraph => "gitGraph",
|
|
LayoutKind::Plugin { .. } => "plugin",
|
|
}
|
|
.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), `"gitGraph"` or `"plugin"`.
|
|
#[serde(default)]
|
|
pub kind: Option<String>,
|
|
/// Plugin origin for `"plugin"` layouts.
|
|
#[serde(default)]
|
|
pub plugin_origin: Option<PluginLayoutOriginDto>,
|
|
/// Opaque initial state for `"plugin"` layouts.
|
|
#[serde(default)]
|
|
pub state: serde_json::Value,
|
|
}
|
|
|
|
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("plugin") | Some("customPluginLayout") => {
|
|
let origin = self.plugin_origin.clone().ok_or_else(|| ErrorDto {
|
|
code: "INVALID".to_owned(),
|
|
message: "pluginOrigin is required for plugin layouts".to_owned(),
|
|
})?;
|
|
Ok(LayoutKind::Plugin {
|
|
plugin_origin: origin.try_into()?,
|
|
state: self.state.clone(),
|
|
})
|
|
}
|
|
Some(other) => Err(ErrorDto {
|
|
code: "INVALID".to_owned(),
|
|
message: format!("unknown layout kind: {other}"),
|
|
}),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Stable provider identity for a plugin layout creation request.
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct PluginLayoutOriginDto {
|
|
/// Provider plugin id.
|
|
pub plugin_id: String,
|
|
/// Layout type declared by the provider.
|
|
pub layout_type: String,
|
|
}
|
|
|
|
impl TryFrom<PluginLayoutOriginDto> for PluginLayoutOrigin {
|
|
type Error = ErrorDto;
|
|
|
|
fn try_from(value: PluginLayoutOriginDto) -> Result<Self, Self::Error> {
|
|
let plugin_id = PluginId::new(value.plugin_id).map_err(|e| ErrorDto {
|
|
code: "INVALID".to_owned(),
|
|
message: e.to_string(),
|
|
})?;
|
|
let layout_type = PluginLayoutType::new(value.layout_type).map_err(|e| ErrorDto {
|
|
code: "INVALID".to_owned(),
|
|
message: e.to_string(),
|
|
})?;
|
|
Ok(Self {
|
|
plugin_id,
|
|
layout_type,
|
|
})
|
|
}
|
|
}
|
|
|
|
/// 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::{
|
|
CloneOpenCodeProfileFromSeedInput, CloneOpenCodeProfileFromSeedOutput, ConfigureProfilesInput,
|
|
ConfigureProfilesOutput, DeleteProfileInput, DetectProfilesInput, DetectProfilesOutput,
|
|
FirstRunStateOutput, ListProfilesOutput, ProfileAvailability, ReferenceProfilesOutput,
|
|
SaveOpenCodeProviderProfileInput, SaveOpenCodeProviderProfileOutput, SaveProfileInput,
|
|
SaveProfileOutput,
|
|
};
|
|
use domain::profile::{AgentProfile, CustomProviderConfig, OpenCodeConfig};
|
|
use domain::ProfileId;
|
|
|
|
/// A profile crossing the wire. [`AgentProfile`] already serialises camelCase
|
|
/// (id, name, command, args, `contextInjection{strategy,…}`, detect,
|
|
/// `cwdTemplate`), so we embed it directly — the TS mirror matches this shape.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(transparent)]
|
|
pub struct ProfileDto(pub AgentProfile);
|
|
|
|
/// A list of profiles (camelCase array on the wire).
|
|
#[derive(Debug, Clone, Serialize)]
|
|
#[serde(transparent)]
|
|
pub struct ProfileListDto(pub Vec<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)
|
|
}
|
|
}
|
|
|
|
impl From<CloneOpenCodeProfileFromSeedOutput> for ProfileDto {
|
|
fn from(out: CloneOpenCodeProfileFromSeedOutput) -> Self {
|
|
Self(out.profile)
|
|
}
|
|
}
|
|
|
|
impl From<application::CloneProfileFromSeedOutput> for ProfileDto {
|
|
fn from(out: application::CloneProfileFromSeedOutput) -> Self {
|
|
Self(out.profile)
|
|
}
|
|
}
|
|
|
|
/// One entry of a curated structured-profile model catalogue.
|
|
#[derive(Debug, Clone, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct ProfileModelCatalogEntryDto {
|
|
/// Structured adapter this model belongs to.
|
|
pub adapter: domain::profile::StructuredAdapter,
|
|
/// Exact model identifier to persist on `AgentProfile.model`.
|
|
pub model_id: String,
|
|
/// Human-readable label for picker display.
|
|
pub display_name: String,
|
|
/// Extra search tokens useful to the frontend.
|
|
pub aliases: Vec<String>,
|
|
/// Whether this entry is the conservative default suggestion.
|
|
pub recommended: bool,
|
|
/// Compatibility state against the locally detected CLI version.
|
|
pub compatibility: domain::ModelCompatibility,
|
|
/// Source that contributed the model entry.
|
|
pub source: domain::ModelCatalogSource,
|
|
}
|
|
|
|
impl From<application::ProfileModelCatalogEntry> for ProfileModelCatalogEntryDto {
|
|
fn from(entry: application::ProfileModelCatalogEntry) -> Self {
|
|
Self {
|
|
adapter: entry.adapter,
|
|
model_id: entry.model_id,
|
|
display_name: entry.display_name,
|
|
aliases: entry.aliases,
|
|
recommended: entry.recommended,
|
|
compatibility: entry.compatibility,
|
|
source: entry.source,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Enriched structured-profile model catalogue.
|
|
#[derive(Debug, Clone, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct ProfileModelCatalogDto {
|
|
/// The catalogue entries.
|
|
pub models: Vec<ProfileModelCatalogEntryDto>,
|
|
/// Best-effort local CLI version.
|
|
pub cli_version: Option<String>,
|
|
/// Non-fatal fallback/degradation warnings.
|
|
pub warnings: Vec<String>,
|
|
}
|
|
|
|
impl From<application::ListClaudeModelsOutput> for ProfileModelCatalogDto {
|
|
fn from(out: application::ListClaudeModelsOutput) -> Self {
|
|
Self {
|
|
models: out.models.into_iter().map(Into::into).collect(),
|
|
cli_version: out.cli_version.map(|version| version.raw),
|
|
warnings: out.warnings,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<application::ListCodexModelsOutput> for ProfileModelCatalogDto {
|
|
fn from(out: application::ListCodexModelsOutput) -> Self {
|
|
Self {
|
|
models: out.models.into_iter().map(Into::into).collect(),
|
|
cli_version: out.cli_version.map(|version| version.raw),
|
|
warnings: out.warnings,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// One entry of the static OpenCode cloud-provider catalogue (ticket #92, lot B3).
|
|
#[derive(Debug, Clone, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct OpenCodeProviderDto {
|
|
/// Identifier in the OpenCode provider registry (e.g. `"anthropic"`).
|
|
pub provider_id: String,
|
|
/// Human-readable label for the picker UI.
|
|
pub display_name: String,
|
|
/// Model names this provider serves, offered for selection.
|
|
pub models: Vec<String>,
|
|
}
|
|
|
|
impl From<application::OpenCodeProviderCatalogEntry> for OpenCodeProviderDto {
|
|
fn from(entry: application::OpenCodeProviderCatalogEntry) -> Self {
|
|
Self {
|
|
provider_id: entry.provider_id,
|
|
display_name: entry.display_name,
|
|
models: entry.models,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A list of OpenCode cloud-provider catalogue entries (camelCase array on the
|
|
/// wire).
|
|
#[derive(Debug, Clone, Serialize)]
|
|
#[serde(transparent)]
|
|
pub struct OpenCodeProviderListDto(pub Vec<OpenCodeProviderDto>);
|
|
|
|
impl From<application::ListOpenCodeProvidersOutput> for OpenCodeProviderListDto {
|
|
fn from(out: application::ListOpenCodeProvidersOutput) -> Self {
|
|
Self(out.providers.into_iter().map(Into::into).collect())
|
|
}
|
|
}
|
|
|
|
/// 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 `save_opencode_provider_profile` (ticket #92, lot B3): the
|
|
/// profile to upsert plus the literal provider fields. `apiKey` never reaches
|
|
/// `profiles.json` — the use case seals it into the `SecretStore`.
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct SaveOpenCodeProviderProfileRequestDto {
|
|
/// The profile to create or replace (by id).
|
|
pub profile: AgentProfile,
|
|
/// Provider id in the OpenCode registry (e.g. `"anthropic"`).
|
|
pub provider_id: String,
|
|
/// Model name served by this provider.
|
|
pub model: String,
|
|
/// Literal API key, sealed into the `SecretStore` — never persisted as-is.
|
|
pub api_key: String,
|
|
/// Optional custom-provider configuration (endpoint outside the OpenCode
|
|
/// registry). Absent/`null` = known provider (unchanged behaviour).
|
|
#[serde(default)]
|
|
pub custom: Option<CustomProviderConfig>,
|
|
}
|
|
|
|
impl From<SaveOpenCodeProviderProfileRequestDto> for SaveOpenCodeProviderProfileInput {
|
|
fn from(dto: SaveOpenCodeProviderProfileRequestDto) -> Self {
|
|
Self {
|
|
profile: dto.profile,
|
|
provider_id: dto.provider_id,
|
|
model: dto.model,
|
|
api_key: dto.api_key,
|
|
custom: dto.custom,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<SaveOpenCodeProviderProfileOutput> for ProfileDto {
|
|
fn from(out: SaveOpenCodeProviderProfileOutput) -> Self {
|
|
Self(out.profile)
|
|
}
|
|
}
|
|
|
|
/// Request DTO for `clone_opencode_profile_from_seed`.
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct CloneOpenCodeProfileFromSeedRequestDto {
|
|
/// Optional display name for the new profile.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub name: Option<String>,
|
|
/// Optional OpenCode config override. When omitted, the seed config is copied.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub opencode: Option<OpenCodeConfig>,
|
|
}
|
|
|
|
impl From<CloneOpenCodeProfileFromSeedRequestDto> for CloneOpenCodeProfileFromSeedInput {
|
|
fn from(dto: CloneOpenCodeProfileFromSeedRequestDto) -> Self {
|
|
Self {
|
|
name: dto.name,
|
|
opencode: dto.opencode,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Request DTO for `clone_profile_from_seed`.
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct CloneProfileFromSeedRequestDto {
|
|
/// Id of the persisted or reference profile to clone.
|
|
pub seed_profile_id: domain::ids::ProfileId,
|
|
/// Optional display name for the new profile.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub name: Option<String>,
|
|
/// Optional model override. When omitted, the seed model is copied.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub model: Option<String>,
|
|
}
|
|
|
|
impl From<CloneProfileFromSeedRequestDto> for application::CloneProfileFromSeedInput {
|
|
fn from(dto: CloneProfileFromSeedRequestDto) -> Self {
|
|
Self {
|
|
seed_profile_id: dto.seed_profile_id,
|
|
name: dto.name,
|
|
model: dto.model,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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,
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Local model servers (B35)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
use application::{
|
|
ListModelServersOutput, ModelArtifactView, ModelServerListItem, SaveModelServerInput,
|
|
SaveModelServerOutput,
|
|
};
|
|
use domain::model_server::{
|
|
ExecutablePath, HfModelRef, LlamaCppOptions, LocalModelRef, LocalModelServerConfig,
|
|
LocalModelServerKind, ModelPath, ModelServerEndpoint, ModelSource,
|
|
};
|
|
use domain::{LocalModelServerId, StopPolicy};
|
|
|
|
/// Local model-server implementation on the IPC wire.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub enum ModelServerKindDto {
|
|
/// llama.cpp `llama-server`.
|
|
LlamaCpp,
|
|
}
|
|
|
|
impl From<LocalModelServerKind> for ModelServerKindDto {
|
|
fn from(kind: LocalModelServerKind) -> Self {
|
|
match kind {
|
|
LocalModelServerKind::LlamaCpp => Self::LlamaCpp,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<ModelServerKindDto> for LocalModelServerKind {
|
|
fn from(kind: ModelServerKindDto) -> Self {
|
|
match kind {
|
|
ModelServerKindDto::LlamaCpp => Self::LlamaCpp,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Stop policy on the IPC wire.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub enum StopPolicyDto {
|
|
/// Keep the process alive.
|
|
KeepAlive,
|
|
/// Stop when no longer used.
|
|
StopWhenUnused,
|
|
/// Stop when the application exits.
|
|
StopOnAppExit,
|
|
}
|
|
|
|
impl From<StopPolicy> for StopPolicyDto {
|
|
fn from(policy: StopPolicy) -> Self {
|
|
match policy {
|
|
StopPolicy::KeepAlive => Self::KeepAlive,
|
|
StopPolicy::StopWhenUnused => Self::StopWhenUnused,
|
|
StopPolicy::StopOnAppExit => Self::StopOnAppExit,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<StopPolicyDto> for StopPolicy {
|
|
fn from(policy: StopPolicyDto) -> Self {
|
|
match policy {
|
|
StopPolicyDto::KeepAlive => Self::KeepAlive,
|
|
StopPolicyDto::StopWhenUnused => Self::StopWhenUnused,
|
|
StopPolicyDto::StopOnAppExit => Self::StopOnAppExit,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Model source on the IPC wire.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase", tag = "type")]
|
|
pub enum ModelSourceDto {
|
|
/// Local `.gguf` path.
|
|
LocalPath {
|
|
/// Absolute local path.
|
|
path: String,
|
|
},
|
|
/// Hugging Face `namespace/repo[:quant]` reference.
|
|
HuggingFace {
|
|
/// Repository reference.
|
|
repo: String,
|
|
},
|
|
}
|
|
|
|
impl From<ModelSource> for ModelSourceDto {
|
|
fn from(source: ModelSource) -> Self {
|
|
match source {
|
|
ModelSource::LocalPath { path } => Self::LocalPath { path: path.0 },
|
|
ModelSource::HuggingFace { repo } => Self::HuggingFace { repo: repo.0 },
|
|
}
|
|
}
|
|
}
|
|
|
|
impl ModelSourceDto {
|
|
fn into_domain(self) -> Result<ModelSource, ErrorDto> {
|
|
match self {
|
|
Self::LocalPath { path } => Ok(ModelSource::LocalPath {
|
|
path: ModelPath::new(path).map_err(invalid_domain_error)?,
|
|
}),
|
|
Self::HuggingFace { repo } => Ok(ModelSource::HuggingFace {
|
|
repo: HfModelRef::new(repo).map_err(invalid_domain_error)?,
|
|
}),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A local model-server config crossing the wire.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct ModelServerConfigDto {
|
|
/// Stable local server config id.
|
|
pub id: String,
|
|
/// Server implementation kind.
|
|
pub kind: ModelServerKindDto,
|
|
/// Display name.
|
|
pub name: String,
|
|
/// OpenAI-compatible base URL.
|
|
#[serde(rename = "baseURL")]
|
|
pub base_url: String,
|
|
/// TCP port.
|
|
pub port: u16,
|
|
/// Optional explicit model source.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub model_source: Option<ModelSourceDto>,
|
|
/// Legacy V1 input alias for `modelSource:{type:"localPath",path}`.
|
|
#[serde(default, skip_serializing)]
|
|
pub model_path: Option<String>,
|
|
/// Served model name exposed to OpenCode.
|
|
pub served_model_name: String,
|
|
/// Optional explicit `llama-server` executable path.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub binary_path: Option<String>,
|
|
/// llama.cpp host passed to `--host`.
|
|
#[serde(default = "default_llamacpp_host")]
|
|
pub host: String,
|
|
/// llama.cpp GPU layers passed to `-ngl`.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub gpu_layers: Option<u32>,
|
|
/// llama.cpp context size passed to `-c`.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub context_size: Option<u32>,
|
|
/// Whether to pass `--jinja`.
|
|
#[serde(default)]
|
|
pub jinja: bool,
|
|
/// Extra argv entries.
|
|
#[serde(default)]
|
|
pub args: Vec<String>,
|
|
/// Whether IdeA should start the server lazily.
|
|
pub auto_start: bool,
|
|
/// Stop policy.
|
|
pub stop_policy: StopPolicyDto,
|
|
/// Optional readiness warmup deadline override in seconds.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub warmup_deadline_secs: Option<u64>,
|
|
/// Derived local artifact cache state.
|
|
#[serde(default)]
|
|
pub artifact: ModelArtifactDto,
|
|
}
|
|
|
|
impl ModelServerConfigDto {
|
|
/// Maps a domain config to the flat IPC DTO.
|
|
#[must_use]
|
|
pub fn from_domain(config: LocalModelServerConfig) -> Self {
|
|
Self {
|
|
id: config.id.to_string(),
|
|
kind: config.kind.into(),
|
|
name: config.name,
|
|
base_url: config.endpoint.base_url,
|
|
port: config.endpoint.port,
|
|
model_source: config.model.source.map(Into::into),
|
|
model_path: None,
|
|
served_model_name: config.model.served_name,
|
|
binary_path: config.binary.map(|binary| binary.as_str().to_owned()),
|
|
host: config.options.host,
|
|
gpu_layers: config.options.gpu_layers,
|
|
context_size: config.options.context_size,
|
|
jinja: config.options.jinja,
|
|
args: config.args,
|
|
auto_start: config.auto_start,
|
|
stop_policy: config.stop_policy.into(),
|
|
warmup_deadline_secs: config.warmup_deadline_secs,
|
|
artifact: ModelArtifactDto::NotManaged,
|
|
}
|
|
}
|
|
|
|
/// Maps the flat IPC DTO into the domain config, preserving internal model id
|
|
/// from the existing config when available.
|
|
///
|
|
/// # Errors
|
|
/// [`ErrorDto`] with `INVALID` code if any domain invariant rejects the input.
|
|
pub fn into_domain(
|
|
self,
|
|
existing: Option<&LocalModelServerConfig>,
|
|
) -> Result<LocalModelServerConfig, ErrorDto> {
|
|
let server_id = parse_model_server_id(&self.id)?;
|
|
let endpoint = ModelServerEndpoint::new(self.base_url.clone(), self.port)
|
|
.map_err(invalid_domain_error)?;
|
|
let source = resolve_model_source(self.model_source, self.model_path)?;
|
|
let model_id = existing
|
|
.filter(|config| config.id == server_id)
|
|
.map(|config| config.model.id.clone())
|
|
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
|
|
let label = non_empty_or_fallback(&self.served_model_name, &self.name);
|
|
let model = LocalModelRef::new(model_id, label, source, self.served_model_name.clone())
|
|
.map_err(invalid_domain_error)?;
|
|
let binary = optional_non_empty(self.binary_path)
|
|
.map(ExecutablePath::new)
|
|
.transpose()
|
|
.map_err(invalid_domain_error)?;
|
|
let options =
|
|
LlamaCppOptions::new(self.host, self.gpu_layers, self.context_size, self.jinja)
|
|
.map_err(invalid_domain_error)?;
|
|
LocalModelServerConfig::new(
|
|
server_id,
|
|
self.kind.into(),
|
|
self.name,
|
|
endpoint,
|
|
model,
|
|
binary,
|
|
options,
|
|
self.args,
|
|
self.auto_start,
|
|
self.stop_policy.into(),
|
|
)
|
|
.and_then(|config| config.with_warmup_deadline_secs(self.warmup_deadline_secs))
|
|
.map_err(invalid_domain_error)
|
|
}
|
|
}
|
|
|
|
/// Local model artifact cache state on the IPC wire.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase", tag = "state")]
|
|
pub enum ModelArtifactDto {
|
|
/// The configured source is not managed by IdeA's downloader.
|
|
NotManaged,
|
|
/// The configured source is managed but not present in cache.
|
|
Missing,
|
|
/// A download/prepare operation is currently running for this server.
|
|
Downloading,
|
|
/// The configured source is present in cache.
|
|
Downloaded {
|
|
/// Local artifact path.
|
|
path: String,
|
|
/// Total on-disk size when known.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
size_bytes: Option<u64>,
|
|
},
|
|
}
|
|
|
|
impl Default for ModelArtifactDto {
|
|
fn default() -> Self {
|
|
Self::NotManaged
|
|
}
|
|
}
|
|
|
|
impl From<ModelArtifactView> for ModelArtifactDto {
|
|
fn from(view: ModelArtifactView) -> Self {
|
|
match view {
|
|
ModelArtifactView::NotManaged => Self::NotManaged,
|
|
ModelArtifactView::Missing => Self::Missing,
|
|
ModelArtifactView::Downloading => Self::Downloading,
|
|
ModelArtifactView::Downloaded { path, size_bytes } => {
|
|
Self::Downloaded { path, size_bytes }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<ModelServerListItem> for ModelServerConfigDto {
|
|
fn from(item: ModelServerListItem) -> Self {
|
|
let mut dto = Self::from_domain(item.config);
|
|
dto.artifact = item.artifact.into();
|
|
dto
|
|
}
|
|
}
|
|
|
|
/// Response DTO for `preview_model_server_command`.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct PreviewModelServerCommandDto {
|
|
/// Executable command.
|
|
pub command: String,
|
|
/// Arguments without shell parsing.
|
|
pub args: Vec<String>,
|
|
/// Human-readable escaped command line.
|
|
pub display: String,
|
|
}
|
|
|
|
/// List response for local model servers.
|
|
#[derive(Debug, Clone, Serialize)]
|
|
#[serde(transparent)]
|
|
pub struct ModelServerConfigListDto(pub Vec<ModelServerConfigDto>);
|
|
|
|
impl From<ListModelServersOutput> for ModelServerConfigListDto {
|
|
fn from(out: ListModelServersOutput) -> Self {
|
|
Self(
|
|
out.servers
|
|
.into_iter()
|
|
.map(ModelServerConfigDto::from)
|
|
.collect(),
|
|
)
|
|
}
|
|
}
|
|
|
|
/// Request DTO for `save_model_server`.
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct SaveModelServerRequestDto {
|
|
/// Config to upsert.
|
|
pub config: ModelServerConfigDto,
|
|
}
|
|
|
|
impl From<SaveModelServerOutput> for ModelServerConfigDto {
|
|
fn from(out: SaveModelServerOutput) -> Self {
|
|
Self::from_domain(out.config)
|
|
}
|
|
}
|
|
|
|
/// Parses a local model-server id string.
|
|
///
|
|
/// # Errors
|
|
/// [`ErrorDto`] with `INVALID` code if the string is not a UUID.
|
|
pub fn parse_model_server_id(raw: &str) -> Result<LocalModelServerId, ErrorDto> {
|
|
uuid::Uuid::parse_str(raw)
|
|
.map(LocalModelServerId::from_uuid)
|
|
.map_err(|_| ErrorDto {
|
|
code: "INVALID".to_owned(),
|
|
message: format!("invalid model server id: {raw}"),
|
|
})
|
|
}
|
|
|
|
/// Builds a save-model-server input after the caller has resolved the existing
|
|
/// config, if any.
|
|
///
|
|
/// # Errors
|
|
/// [`ErrorDto`] if DTO-to-domain validation fails.
|
|
pub fn save_model_server_input(
|
|
request: SaveModelServerRequestDto,
|
|
existing: Option<&LocalModelServerConfig>,
|
|
) -> Result<SaveModelServerInput, ErrorDto> {
|
|
Ok(SaveModelServerInput {
|
|
config: request.config.into_domain(existing)?,
|
|
})
|
|
}
|
|
|
|
/// Converts a model-server DTO to domain using the same path as save.
|
|
///
|
|
/// # Errors
|
|
/// [`ErrorDto`] if DTO-to-domain validation fails.
|
|
pub fn model_server_config_domain(
|
|
config: ModelServerConfigDto,
|
|
existing: Option<&LocalModelServerConfig>,
|
|
) -> Result<LocalModelServerConfig, ErrorDto> {
|
|
config.into_domain(existing)
|
|
}
|
|
|
|
fn resolve_model_source(
|
|
model_source: Option<ModelSourceDto>,
|
|
model_path: Option<String>,
|
|
) -> Result<Option<ModelSource>, ErrorDto> {
|
|
let legacy_path = optional_non_empty(model_path);
|
|
match (model_source, legacy_path) {
|
|
(None, None) => Ok(None),
|
|
(None, Some(path)) => Ok(Some(ModelSource::LocalPath {
|
|
path: ModelPath::new(path).map_err(invalid_domain_error)?,
|
|
})),
|
|
(Some(source), None) => Ok(Some(source.into_domain()?)),
|
|
(Some(source), Some(path)) => {
|
|
let domain_source = source.into_domain()?;
|
|
match &domain_source {
|
|
ModelSource::LocalPath { path: source_path } if source_path.as_str() == path => {
|
|
Ok(Some(domain_source))
|
|
}
|
|
_ => Err(ErrorDto {
|
|
code: "INVALID".to_owned(),
|
|
message: "modelPath and modelSource differ".to_owned(),
|
|
}),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn optional_non_empty(raw: Option<String>) -> Option<String> {
|
|
raw.map(|value| value.trim().to_owned())
|
|
.filter(|value| !value.is_empty())
|
|
}
|
|
|
|
fn default_llamacpp_host() -> String {
|
|
"127.0.0.1".to_owned()
|
|
}
|
|
|
|
fn non_empty_or_fallback(primary: &str, fallback: &str) -> String {
|
|
let primary = primary.trim();
|
|
if primary.is_empty() {
|
|
fallback.trim().to_owned()
|
|
} else {
|
|
primary.to_owned()
|
|
}
|
|
}
|
|
|
|
fn invalid_domain_error(error: impl std::fmt::Display) -> ErrorDto {
|
|
ErrorDto {
|
|
code: "INVALID".to_owned(),
|
|
message: error.to_string(),
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Embedder profiles & engines (LOT C2 — §14.5.3)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
use application::{
|
|
DismissChoice, DismissEmbedderSuggestionInput, EmbedderEnginesView, ListEmbedderProfilesOutput,
|
|
OnnxModelView, SaveEmbedderProfileInput, SaveEmbedderProfileOutput,
|
|
};
|
|
use domain::profile::EmbedderProfile;
|
|
|
|
/// An embedder profile crossing the wire. [`EmbedderProfile`] already serialises
|
|
/// camelCase (`id, name, strategy, model?, endpoint?, apiKeyEnv?, dimension`), so we
|
|
/// embed it directly — the TS mirror matches this shape.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(transparent)]
|
|
pub struct EmbedderProfileDto(pub EmbedderProfile);
|
|
|
|
/// A list of embedder profiles (camelCase array on the wire).
|
|
#[derive(Debug, Clone, Serialize)]
|
|
#[serde(transparent)]
|
|
pub struct EmbedderProfileListDto(pub Vec<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::{
|
|
AgentCapability, ChangeAgentProfileOutput, CreateAgentOutput, InspectConversationOutput,
|
|
LaunchAgentOutput, ListAgentsOutput, ReadAgentContextOutput, ReadMcpToolPermissionsOutput,
|
|
};
|
|
use domain::{
|
|
Agent, AgentMcpToolPolicyOverride, EffectivePermissions, EffortSelection, McpToolPolicy,
|
|
PermissionSet, PermissionShadowReport, ProjectPermissions, SkillKind, TerminalSession,
|
|
};
|
|
|
|
/// One discoverable capability carried by an agent.
|
|
#[derive(Debug, Clone, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct AgentCapabilityDto {
|
|
/// Capability display name.
|
|
pub name: String,
|
|
/// One-line affordance description.
|
|
pub description: String,
|
|
/// Capability nature.
|
|
pub kind: SkillKind,
|
|
}
|
|
|
|
impl From<AgentCapability> for AgentCapabilityDto {
|
|
fn from(value: AgentCapability) -> Self {
|
|
Self {
|
|
name: value.name,
|
|
description: value.description,
|
|
kind: value.kind,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// An agent crossing the wire. The raw [`Agent`] shape is flattened so existing
|
|
/// fields, including `skills`, remain compatible; `capabilities` is additive.
|
|
#[derive(Debug, Clone, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct AgentDto {
|
|
/// Raw agent manifest shape.
|
|
#[serde(flatten)]
|
|
pub agent: Agent,
|
|
/// Resolved discoverable capabilities.
|
|
pub capabilities: Vec<AgentCapabilityDto>,
|
|
/// Whether this agent is the effective project orchestrator.
|
|
pub is_orchestrator: bool,
|
|
}
|
|
|
|
impl AgentDto {
|
|
/// Builds a DTO without resolved capabilities.
|
|
#[must_use]
|
|
pub fn from_agent(agent: Agent) -> Self {
|
|
Self {
|
|
agent,
|
|
capabilities: Vec::new(),
|
|
is_orchestrator: false,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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.discovery_entries()
|
|
.into_iter()
|
|
.map(|entry| AgentDto {
|
|
agent: entry.agent,
|
|
capabilities: entry
|
|
.capabilities
|
|
.into_iter()
|
|
.map(AgentCapabilityDto::from)
|
|
.collect(),
|
|
is_orchestrator: entry.is_orchestrator,
|
|
})
|
|
.collect(),
|
|
)
|
|
}
|
|
}
|
|
|
|
impl From<CreateAgentOutput> for AgentDto {
|
|
fn from(out: CreateAgentOutput) -> Self {
|
|
Self::from_agent(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);
|
|
|
|
/// Response for resolving one agent's file/bash permissions.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct ResolveAgentPermissionsResponseDto {
|
|
/// Resolved policy, or `null` when neither project nor agent policy exists.
|
|
pub effective: Option<EffectivePermissions>,
|
|
/// Diagnostic report for agent-level allows shadowed by project defaults.
|
|
pub shadowed: PermissionShadowReport,
|
|
}
|
|
|
|
impl From<application::ResolveAgentPermissionsOutput> for ResolveAgentPermissionsResponseDto {
|
|
fn from(out: application::ResolveAgentPermissionsOutput) -> Self {
|
|
Self {
|
|
effective: out.effective,
|
|
shadowed: out.shadowed,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Full project system permission document crossing the wire.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(transparent)]
|
|
pub struct ProjectSystemPermissionsDto(pub ProjectSystemPermissions);
|
|
|
|
/// Resolved agent system permissions crossing the wire.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(transparent)]
|
|
pub struct ResolvedAgentSystemPermissionsDto(pub ResolvedAgentSystemPermissions);
|
|
|
|
/// Canonical MCP tool catalogue classification crossing the wire.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct McpToolCatalogueDto {
|
|
/// Tools allowed by the default read-only fallback.
|
|
pub read_only_tools: Vec<String>,
|
|
/// Tools treated as writing/action/execution tools.
|
|
pub write_action_tools: Vec<String>,
|
|
}
|
|
|
|
/// Full MCP tool permission state crossing the wire.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct ProjectMcpToolPermissionsDto {
|
|
/// Document format version.
|
|
pub version: u32,
|
|
/// Canonical catalogue classification used for validation and display.
|
|
pub catalogue: McpToolCatalogueDto,
|
|
/// Optional project-wide default MCP tool policy.
|
|
pub project_default: Option<McpToolPolicy>,
|
|
/// Per-agent overrides.
|
|
pub agents: Vec<AgentMcpToolPolicyOverride>,
|
|
}
|
|
|
|
impl From<ReadMcpToolPermissionsOutput> for ProjectMcpToolPermissionsDto {
|
|
fn from(out: ReadMcpToolPermissionsOutput) -> Self {
|
|
Self {
|
|
version: out.permissions.version,
|
|
catalogue: McpToolCatalogueDto {
|
|
read_only_tools: out.catalogue.read_only_tools,
|
|
write_action_tools: out.catalogue.write_action_tools,
|
|
},
|
|
project_default: out.permissions.project_default,
|
|
agents: out.permissions.agents,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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 updating project default system permissions.
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct UpdateProjectSystemPermissionsRequestDto {
|
|
/// Id of the owning project.
|
|
pub project_id: String,
|
|
/// New project defaults. `null` removes defaults.
|
|
pub permissions: Option<SystemPermissionSet>,
|
|
}
|
|
|
|
/// Request DTO for updating one agent system permission override.
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct UpdateAgentSystemPermissionsRequestDto {
|
|
/// 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<SystemPermissionSet>,
|
|
}
|
|
|
|
/// Request DTO for resolving one agent's effective system permissions.
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct ResolveAgentSystemPermissionsRequestDto {
|
|
/// Id of the owning project.
|
|
pub project_id: String,
|
|
/// Target agent id.
|
|
pub agent_id: String,
|
|
}
|
|
|
|
/// Request DTO for updating project default MCP tool permissions.
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct UpdateProjectMcpToolPermissionsRequestDto {
|
|
/// Id of the owning project.
|
|
pub project_id: String,
|
|
/// New project MCP tool policy. `null` removes the default.
|
|
pub policy: Option<McpToolPolicy>,
|
|
}
|
|
|
|
/// Request DTO for updating one agent MCP tool permission override.
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct UpdateAgentMcpToolPermissionsRequestDto {
|
|
/// Id of the owning project.
|
|
pub project_id: String,
|
|
/// Target agent id.
|
|
pub agent_id: String,
|
|
/// New agent MCP tool policy. `null` removes the override.
|
|
pub policy: Option<McpToolPolicy>,
|
|
}
|
|
|
|
/// 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,
|
|
}
|
|
|
|
/// Request DTO for `update_agent_effort`.
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct UpdateAgentEffortRequestDto {
|
|
/// Id of the owning project.
|
|
pub project_id: String,
|
|
/// Id of the agent whose effort override changes.
|
|
pub agent_id: String,
|
|
/// `null` clears the override.
|
|
pub effort: Option<EffortSelection>,
|
|
}
|
|
|
|
/// 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::from_agent(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 adapter-owned channel. The serialised wire twin of a
|
|
/// [`domain::ports::ReplyEvent`]: the `agent_send` pump maps each turn event to
|
|
/// one of these and pushes it to the frontend `AgentChatView`.
|
|
///
|
|
/// Tagged on `kind` (camelCase: `"textDelta"` | `"toolActivity"` | `"final"` |
|
|
/// `"error"`), so the front branches without positional parsing. `Final` is the
|
|
/// normal deterministic terminal chunk of a turn; `Error` is a visible terminal
|
|
/// fallback for an otherwise silent/empty turn. `Deserialize` is derived too so
|
|
/// tests (and a mock gateway) can round-trip it.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(tag = "kind", rename_all = "camelCase")]
|
|
pub enum ReplyChunk {
|
|
/// The user's submitted prompt, retained in live scrollback for reattach.
|
|
#[serde(rename_all = "camelCase")]
|
|
UserPrompt {
|
|
/// Submitted prompt text.
|
|
text: String,
|
|
},
|
|
/// An assistant text fragment (incremental chat rendering).
|
|
#[serde(rename_all = "camelCase")]
|
|
TextDelta {
|
|
/// The text fragment.
|
|
text: String,
|
|
},
|
|
/// A human-readable tool-activity badge (best-effort observability).
|
|
#[serde(rename_all = "camelCase")]
|
|
ToolActivity {
|
|
/// The human-readable activity label.
|
|
label: String,
|
|
},
|
|
/// The deterministic end-of-turn chunk carrying the aggregated final content.
|
|
#[serde(rename_all = "camelCase")]
|
|
Final {
|
|
/// The aggregated final content of the turn.
|
|
content: String,
|
|
},
|
|
/// A visible terminal fallback when the model stream produced no usable answer.
|
|
#[serde(rename_all = "camelCase")]
|
|
Error {
|
|
/// Human-readable explanation to render in the chat cell.
|
|
message: String,
|
|
},
|
|
}
|
|
|
|
/// Response DTO for `reattach_agent_chat`: the retained **conversation
|
|
/// scrollback** of a still-live structured session, replayed into the
|
|
/// re-mounting `AgentChatView` before the new reply stream is wired (§17.6). The
|
|
/// typed twin of [`ReattachResultDto`] (PTY scrollback bytes) — here the
|
|
/// scrollback is the ordered list of [`ReplyChunk`]s already streamed.
|
|
#[derive(Debug, Clone, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct ReattachChatDto {
|
|
/// The session that was re-attached (echoed back for the frontend).
|
|
pub session_id: String,
|
|
/// The chunks already streamed for this conversation, in order. The frontend
|
|
/// replays them to rebuild the visible turns, then receives subsequent chunks
|
|
/// over the freshly-registered channel.
|
|
pub scrollback: Vec<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 background task owned by an agent in the Work panel read model.
|
|
#[derive(Debug, Clone, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct AgentBackgroundTaskStateDto {
|
|
/// Stable task id (UUID string).
|
|
pub task_id: String,
|
|
/// Kind discriminant.
|
|
pub kind: String,
|
|
/// Lifecycle state.
|
|
pub state: String,
|
|
/// Process exit code, when the terminal result carries one.
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub exit_code: Option<i32>,
|
|
/// Human-readable summary / error / reason of the terminal result.
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub summary: Option<String>,
|
|
/// Bounded stdout tail.
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub stdout_tail: Option<String>,
|
|
/// Bounded stderr tail.
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub stderr_tail: Option<String>,
|
|
/// Agent that requested a headless rendezvous, when known.
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub requester_agent_id: Option<String>,
|
|
/// Target agent for a headless rendezvous.
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub target_agent_id: Option<String>,
|
|
/// Conversation opened by a headless rendezvous.
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub conversation_id: Option<String>,
|
|
/// Creation timestamp, epoch milliseconds.
|
|
pub created_at_ms: u64,
|
|
/// Last update timestamp, epoch milliseconds.
|
|
pub updated_at_ms: u64,
|
|
}
|
|
|
|
impl From<AgentBackgroundTaskState> for AgentBackgroundTaskStateDto {
|
|
fn from(task: AgentBackgroundTaskState) -> Self {
|
|
Self {
|
|
task_id: task.task_id.to_string(),
|
|
kind: background_kind_label_from_work_state(task.kind).to_owned(),
|
|
state: background_state_label(task.state).to_owned(),
|
|
exit_code: task.exit_code,
|
|
summary: task.summary,
|
|
stdout_tail: task.stdout_tail,
|
|
stderr_tail: task.stderr_tail,
|
|
requester_agent_id: task.requester_agent_id.map(|id| id.to_string()),
|
|
target_agent_id: task.target_agent_id.map(|id| id.to_string()),
|
|
conversation_id: task.conversation_id.map(|id| id.to_string()),
|
|
created_at_ms: task.created_at_ms,
|
|
updated_at_ms: task.updated_at_ms,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// One manifest agent's current live/busy state.
|
|
#[derive(Debug, Clone, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct AgentWorkStateDto {
|
|
/// Agent id.
|
|
pub agent_id: String,
|
|
/// Agent display name.
|
|
pub name: String,
|
|
/// Runtime profile id assigned to the agent.
|
|
pub profile_id: String,
|
|
/// Live session, if any.
|
|
pub live: Option<LiveWorkSessionDto>,
|
|
/// Current mediated-input busy state.
|
|
pub busy: AgentBusyState,
|
|
/// Pending/in-progress delegation tickets, in FIFO order.
|
|
pub tickets: Vec<AgentTicketStateDto>,
|
|
/// Best-effort first-class background tasks owned by this agent.
|
|
pub background_tasks: Vec<AgentBackgroundTaskStateDto>,
|
|
}
|
|
|
|
/// 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(),
|
|
background_tasks: agent
|
|
.background_tasks
|
|
.into_iter()
|
|
.map(AgentBackgroundTaskStateDto::from)
|
|
.collect(),
|
|
})
|
|
.collect(),
|
|
conversations: state
|
|
.conversations
|
|
.into_iter()
|
|
.map(ConversationWorkSummaryDto::from)
|
|
.collect(),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// App-wide shutdown guard read model for the exit confirmation flow.
|
|
#[derive(Debug, Clone, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct AppExitWorkGuardStateDto {
|
|
/// Whether at least one active work item would be interrupted by app exit.
|
|
pub has_work_in_progress: bool,
|
|
/// Number of busy agents across all open projects.
|
|
pub busy_agent_count: usize,
|
|
/// Number of non-terminal background tasks across all open projects.
|
|
pub active_background_task_count: usize,
|
|
/// Total active work items.
|
|
pub total_work_count: usize,
|
|
/// Best-effort compact details for the confirmation dialog.
|
|
pub details: Vec<AppExitWorkGuardDetailDto>,
|
|
}
|
|
|
|
impl From<AppExitWorkGuardState> for AppExitWorkGuardStateDto {
|
|
fn from(state: AppExitWorkGuardState) -> Self {
|
|
Self {
|
|
has_work_in_progress: state.has_work_in_progress,
|
|
busy_agent_count: state.busy_agent_count,
|
|
active_background_task_count: state.active_background_task_count,
|
|
total_work_count: state.busy_agent_count + state.active_background_task_count,
|
|
details: state
|
|
.details
|
|
.into_iter()
|
|
.map(AppExitWorkGuardDetailDto::from)
|
|
.collect(),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// One active work item contributing to [`AppExitWorkGuardStateDto`].
|
|
#[derive(Debug, Clone, Serialize)]
|
|
#[serde(rename_all = "camelCase", tag = "kind")]
|
|
pub enum AppExitWorkGuardDetailDto {
|
|
/// A manifest agent is currently processing a turn.
|
|
BusyAgent {
|
|
/// Owning project id.
|
|
project_id: String,
|
|
/// Owning project display name.
|
|
project_name: String,
|
|
/// Agent id.
|
|
agent_id: String,
|
|
/// Agent display name.
|
|
agent_name: String,
|
|
/// Busy ticket id, when available.
|
|
ticket_id: Option<String>,
|
|
},
|
|
/// A first-class background task is queued, running or waiting.
|
|
ActiveBackgroundTask {
|
|
/// Owning project id.
|
|
project_id: String,
|
|
/// Owning project display name.
|
|
project_name: String,
|
|
/// Owning agent id.
|
|
agent_id: String,
|
|
/// Owning agent display name.
|
|
agent_name: String,
|
|
/// Stable task id.
|
|
task_id: String,
|
|
/// Lifecycle state.
|
|
state: String,
|
|
/// Kind discriminant.
|
|
task_kind: String,
|
|
},
|
|
}
|
|
|
|
impl From<AppExitWorkGuardDetail> for AppExitWorkGuardDetailDto {
|
|
fn from(detail: AppExitWorkGuardDetail) -> Self {
|
|
match detail {
|
|
AppExitWorkGuardDetail::BusyAgent {
|
|
project_id,
|
|
project_name,
|
|
agent_id,
|
|
agent_name,
|
|
ticket_id,
|
|
} => Self::BusyAgent {
|
|
project_id: project_id.to_string(),
|
|
project_name,
|
|
agent_id: agent_id.to_string(),
|
|
agent_name,
|
|
ticket_id: ticket_id.map(|id| id.to_string()),
|
|
},
|
|
AppExitWorkGuardDetail::ActiveBackgroundTask {
|
|
project_id,
|
|
project_name,
|
|
agent_id,
|
|
agent_name,
|
|
task_id,
|
|
state,
|
|
kind,
|
|
} => Self::ActiveBackgroundTask {
|
|
project_id: project_id.to_string(),
|
|
project_name,
|
|
agent_id: agent_id.to_string(),
|
|
agent_name,
|
|
task_id: task_id.to_string(),
|
|
state: background_state_label(state).to_owned(),
|
|
task_kind: background_kind_label_from_work_state(kind).to_owned(),
|
|
},
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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, ReadTemplateOutput, 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<ReadTemplateOutput> for TemplateDto {
|
|
fn from(out: ReadTemplateOutput) -> 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,
|
|
/// Optional one-line affordance description.
|
|
#[serde(default)]
|
|
pub description: Option<String>,
|
|
/// Initial Markdown content.
|
|
pub content: String,
|
|
/// Scope the skill is created in.
|
|
pub scope: SkillScope,
|
|
/// Capability nature. Missing legacy clients create workflow skills.
|
|
#[serde(default)]
|
|
pub kind: SkillKind,
|
|
}
|
|
|
|
/// 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());
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Background tasks (B8 — first-class background command)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
use domain::{
|
|
BackgroundTask, BackgroundTaskKind, BackgroundTaskResult, BackgroundTaskState, TaskId,
|
|
};
|
|
|
|
/// One first-class background task as seen by the frontend (camelCase wire shape).
|
|
///
|
|
/// Mirrors the persisted [`BackgroundTask`], flattening the terminal
|
|
/// [`BackgroundTaskResult`] into optional `exitCode` / `summary` / tails so the
|
|
/// UI has a single shape for every lifecycle state. Optional fields are omitted
|
|
/// from the wire when absent (`skip_serializing_if`).
|
|
#[derive(Debug, Clone, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct BackgroundTaskDto {
|
|
/// Stable task id (UUID string).
|
|
pub task_id: String,
|
|
/// Owning agent id (UUID string).
|
|
pub owner_agent_id: String,
|
|
/// Owning project id (UUID string).
|
|
pub project_id: String,
|
|
/// Kind discriminant (`"command"`, `"headlessRendezvous"`, `"sessionResume"`,
|
|
/// `"maintenance"`).
|
|
pub kind: String,
|
|
/// Lifecycle state (`"queued"`, `"running"`, `"waiting"`, `"completed"`,
|
|
/// `"failed"`, `"cancelled"`, `"expired"`).
|
|
pub state: String,
|
|
/// Process exit code, when the terminal result carries one.
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub exit_code: Option<i32>,
|
|
/// Human-readable summary / error / reason of the terminal result.
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub summary: Option<String>,
|
|
/// Bounded stdout tail (merged output for PTY-backed commands).
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub stdout_tail: Option<String>,
|
|
/// Bounded stderr tail (unset for PTY-backed commands, which merge streams).
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub stderr_tail: Option<String>,
|
|
/// Agent that requested a headless rendezvous, when known.
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub requester_agent_id: Option<String>,
|
|
/// Target agent for a headless rendezvous.
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub target_agent_id: Option<String>,
|
|
/// Conversation opened by a headless rendezvous.
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub conversation_id: Option<String>,
|
|
/// Creation timestamp, epoch milliseconds.
|
|
pub created_at_ms: u64,
|
|
/// Last update timestamp, epoch milliseconds.
|
|
pub updated_at_ms: u64,
|
|
}
|
|
|
|
/// Kind discriminant string for a [`BackgroundTaskKind`].
|
|
fn background_kind_label(kind: &BackgroundTaskKind) -> &'static str {
|
|
match kind {
|
|
BackgroundTaskKind::Command { .. } => "command",
|
|
BackgroundTaskKind::HeadlessRendezvous { .. } => "headlessRendezvous",
|
|
BackgroundTaskKind::SessionResume { .. } => "sessionResume",
|
|
BackgroundTaskKind::Maintenance { .. } => "maintenance",
|
|
}
|
|
}
|
|
|
|
/// Kind discriminant string for a work-state [`BackgroundTaskKindLabel`].
|
|
fn background_kind_label_from_work_state(kind: BackgroundTaskKindLabel) -> &'static str {
|
|
match kind {
|
|
BackgroundTaskKindLabel::Command => "command",
|
|
BackgroundTaskKindLabel::HeadlessRendezvous => "headlessRendezvous",
|
|
BackgroundTaskKindLabel::SessionResume => "sessionResume",
|
|
BackgroundTaskKindLabel::Maintenance => "maintenance",
|
|
}
|
|
}
|
|
|
|
/// Lifecycle state string for a [`BackgroundTaskState`].
|
|
fn background_state_label(state: BackgroundTaskState) -> &'static str {
|
|
match state {
|
|
BackgroundTaskState::Queued => "queued",
|
|
BackgroundTaskState::Running => "running",
|
|
BackgroundTaskState::Waiting => "waiting",
|
|
BackgroundTaskState::Completed => "completed",
|
|
BackgroundTaskState::Failed => "failed",
|
|
BackgroundTaskState::Cancelled => "cancelled",
|
|
BackgroundTaskState::Expired => "expired",
|
|
}
|
|
}
|
|
|
|
impl From<BackgroundTask> for BackgroundTaskDto {
|
|
fn from(task: BackgroundTask) -> Self {
|
|
let (requester_agent_id, target_agent_id, conversation_id) =
|
|
background_rendezvous_context_labels(&task.kind);
|
|
let (exit_code, summary, stdout_tail, stderr_tail) = match &task.result {
|
|
Some(BackgroundTaskResult::Success {
|
|
exit_code,
|
|
summary,
|
|
stdout_tail,
|
|
stderr_tail,
|
|
..
|
|
}) => (
|
|
*exit_code,
|
|
Some(summary.clone()),
|
|
stdout_tail.clone(),
|
|
stderr_tail.clone(),
|
|
),
|
|
Some(BackgroundTaskResult::Failure {
|
|
exit_code,
|
|
error,
|
|
stdout_tail,
|
|
stderr_tail,
|
|
..
|
|
}) => (
|
|
*exit_code,
|
|
Some(error.clone()),
|
|
stdout_tail.clone(),
|
|
stderr_tail.clone(),
|
|
),
|
|
Some(BackgroundTaskResult::Cancelled { reason, .. })
|
|
| Some(BackgroundTaskResult::Expired { reason, .. }) => {
|
|
(None, Some(reason.clone()), None, None)
|
|
}
|
|
None => (None, None, None, None),
|
|
};
|
|
Self {
|
|
task_id: task.id.to_string(),
|
|
owner_agent_id: task.owner_agent_id.to_string(),
|
|
project_id: task.project_id.to_string(),
|
|
kind: background_kind_label(&task.kind).to_owned(),
|
|
state: background_state_label(task.state).to_owned(),
|
|
exit_code,
|
|
summary,
|
|
stdout_tail,
|
|
stderr_tail,
|
|
requester_agent_id,
|
|
target_agent_id,
|
|
conversation_id,
|
|
created_at_ms: task.created_at_ms,
|
|
updated_at_ms: task.updated_at_ms,
|
|
}
|
|
}
|
|
}
|
|
|
|
fn background_rendezvous_context_labels(
|
|
kind: &BackgroundTaskKind,
|
|
) -> (Option<String>, Option<String>, Option<String>) {
|
|
match kind {
|
|
BackgroundTaskKind::HeadlessRendezvous {
|
|
requester_agent_id,
|
|
target_agent_id,
|
|
conversation_id,
|
|
..
|
|
} => (
|
|
requester_agent_id.map(|id| id.to_string()),
|
|
Some(target_agent_id.to_string()),
|
|
Some(conversation_id.to_string()),
|
|
),
|
|
_ => (None, None, None),
|
|
}
|
|
}
|
|
|
|
/// Parses a task-id string (UUID) coming from the frontend.
|
|
///
|
|
/// # Errors
|
|
/// Returns an [`ErrorDto`] with code `INVALID` if the string is not a UUID.
|
|
pub fn parse_task_id(raw: &str) -> Result<TaskId, ErrorDto> {
|
|
uuid::Uuid::parse_str(raw)
|
|
.map(TaskId::from_uuid)
|
|
.map_err(|_| ErrorDto {
|
|
code: "INVALID".to_owned(),
|
|
message: format!("invalid task id: {raw}"),
|
|
})
|
|
}
|
|
|
|
/// Request DTO for `spawn_background_command`.
|
|
///
|
|
/// Builds a command-backed [`BackgroundTask`]: the command line runs under the
|
|
/// project host's PTY and its completion is delivered to `ownerAgentId`.
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct SpawnBackgroundCommandRequestDto {
|
|
/// Owning project id (UUID string).
|
|
pub project_id: String,
|
|
/// Agent that owns completion delivery (UUID string).
|
|
pub owner_agent_id: String,
|
|
/// Human-facing label.
|
|
pub label: String,
|
|
/// Executable to run.
|
|
pub command: String,
|
|
/// Arguments.
|
|
#[serde(default)]
|
|
pub args: Vec<String>,
|
|
/// Working directory (absolute path).
|
|
pub cwd: String,
|
|
/// Extra environment variables.
|
|
#[serde(default)]
|
|
pub env: Vec<(String, String)>,
|
|
/// When `true` (the default), wake the owner on completion; otherwise only
|
|
/// record it.
|
|
#[serde(default)]
|
|
pub record_only: bool,
|
|
/// Optional absolute deadline, epoch milliseconds.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub deadline_ms: Option<u64>,
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use application::McpToolPermissionCatalogue;
|
|
use domain::mailbox::TicketId;
|
|
use domain::{AgentId, ConversationId, PermissionShadowReport, ProjectMcpToolPermissions};
|
|
use serde_json::json;
|
|
use uuid::Uuid;
|
|
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn project_mcp_tool_permissions_dto_uses_stable_camel_case_contract() {
|
|
let known_tools = ["idea_ticket_read", "idea_ticket_update"];
|
|
let agent_id = AgentId::from_uuid(Uuid::from_u128(42));
|
|
let output = ReadMcpToolPermissionsOutput {
|
|
catalogue: McpToolPermissionCatalogue::new(
|
|
vec!["idea_ticket_read".to_owned()],
|
|
vec!["idea_ticket_update".to_owned()],
|
|
)
|
|
.unwrap(),
|
|
permissions: ProjectMcpToolPermissions {
|
|
version: 1,
|
|
project_default: Some(
|
|
McpToolPolicy::new(vec!["idea_ticket_read".to_owned()], &known_tools).unwrap(),
|
|
),
|
|
agents: vec![AgentMcpToolPolicyOverride::new(
|
|
agent_id,
|
|
McpToolPolicy::new(vec!["idea_ticket_update".to_owned()], &known_tools)
|
|
.unwrap(),
|
|
)],
|
|
},
|
|
};
|
|
|
|
let value = serde_json::to_value(ProjectMcpToolPermissionsDto::from(output)).unwrap();
|
|
|
|
assert_eq!(
|
|
value,
|
|
json!({
|
|
"version": 1,
|
|
"catalogue": {
|
|
"readOnlyTools": ["idea_ticket_read"],
|
|
"writeActionTools": ["idea_ticket_update"]
|
|
},
|
|
"projectDefault": {
|
|
"allowedTools": ["idea_ticket_read"]
|
|
},
|
|
"agents": [{
|
|
"agentId": agent_id,
|
|
"policy": {
|
|
"allowedTools": ["idea_ticket_update"]
|
|
}
|
|
}]
|
|
})
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn resolve_agent_permissions_response_dto_uses_effective_plus_shadowed_shape() {
|
|
let dto = ResolveAgentPermissionsResponseDto {
|
|
effective: None,
|
|
shadowed: PermissionShadowReport {
|
|
read: false,
|
|
write: false,
|
|
delete: false,
|
|
execute_bash: true,
|
|
fallback: true,
|
|
},
|
|
};
|
|
|
|
assert_eq!(
|
|
serde_json::to_value(dto).unwrap(),
|
|
json!({
|
|
"effective": null,
|
|
"shadowed": {
|
|
"read": false,
|
|
"write": false,
|
|
"delete": false,
|
|
"executeBash": true,
|
|
"fallback": true
|
|
}
|
|
})
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn dto_plugins_workspace_requests_use_stable_camel_case_contract() {
|
|
let path = PluginWorkspacePathDto {
|
|
project_id: Uuid::from_u128(124).to_string(),
|
|
path: "src/main.rs".to_owned(),
|
|
};
|
|
let text = PluginWorkspaceWriteTextDto {
|
|
project_id: path.project_id.clone(),
|
|
path: path.path.clone(),
|
|
content: "fn main() {}\n".to_owned(),
|
|
};
|
|
let binary = PluginWorkspaceWriteBinaryDto {
|
|
project_id: path.project_id.clone(),
|
|
path: "assets/icon.bin".to_owned(),
|
|
bytes: vec![1, 2, 3],
|
|
};
|
|
|
|
assert_eq!(
|
|
serde_json::to_value(&path).unwrap(),
|
|
json!({
|
|
"projectId": path.project_id,
|
|
"path": "src/main.rs"
|
|
})
|
|
);
|
|
assert_eq!(
|
|
serde_json::to_value(&text).unwrap(),
|
|
json!({
|
|
"projectId": text.project_id,
|
|
"path": "src/main.rs",
|
|
"content": "fn main() {}\n"
|
|
})
|
|
);
|
|
assert_eq!(
|
|
serde_json::to_value(&binary).unwrap(),
|
|
json!({
|
|
"projectId": binary.project_id,
|
|
"path": "assets/icon.bin",
|
|
"bytes": [1, 2, 3]
|
|
})
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn dto_plugins_project_structure_query_maps_to_application_input() {
|
|
let dto = PluginProjectStructureQueryDto {
|
|
project_id: Uuid::from_u128(129).to_string(),
|
|
path: Some("crates".to_owned()),
|
|
max_depth: Some(4),
|
|
max_entries: Some(250),
|
|
};
|
|
|
|
let json = serde_json::to_value(&dto).unwrap();
|
|
assert_eq!(
|
|
json,
|
|
json!({
|
|
"projectId": dto.project_id,
|
|
"path": "crates",
|
|
"maxDepth": 4,
|
|
"maxEntries": 250
|
|
})
|
|
);
|
|
|
|
let input: application::QueryProjectStructureInput = dto.into();
|
|
assert_eq!(input.path.as_deref(), Some("crates"));
|
|
assert_eq!(input.max_depth, Some(4));
|
|
assert_eq!(input.max_entries, Some(250));
|
|
}
|
|
|
|
#[test]
|
|
fn dto_plugins_config_document_requests_use_stable_camel_case_contract() {
|
|
let project_id = Uuid::from_u128(130).to_string();
|
|
let read = PluginConfigDocumentReadDto {
|
|
project_id: project_id.clone(),
|
|
path: "config/settings.json".to_owned(),
|
|
format: Some("json".to_owned()),
|
|
};
|
|
assert_eq!(
|
|
serde_json::to_value(&read).unwrap(),
|
|
json!({
|
|
"projectId": project_id,
|
|
"path": "config/settings.json",
|
|
"format": "json"
|
|
})
|
|
);
|
|
let input: application::PluginConfigDocumentReadInput = read.into();
|
|
assert_eq!(input.path, "config/settings.json");
|
|
assert_eq!(input.format.as_deref(), Some("json"));
|
|
|
|
let update = PluginConfigDocumentUpdateDto {
|
|
project_id: Uuid::from_u128(130).to_string(),
|
|
path: "config/settings.json".to_owned(),
|
|
format: Some("json".to_owned()),
|
|
mode: Some("mergePatch".to_owned()),
|
|
value: json!({"enabled": true, "removeMe": null}),
|
|
};
|
|
assert_eq!(
|
|
serde_json::to_value(&update).unwrap(),
|
|
json!({
|
|
"projectId": Uuid::from_u128(130).to_string(),
|
|
"path": "config/settings.json",
|
|
"format": "json",
|
|
"mode": "mergePatch",
|
|
"value": {
|
|
"enabled": true,
|
|
"removeMe": null
|
|
}
|
|
})
|
|
);
|
|
let input: application::PluginConfigDocumentUpdateInput = update.into();
|
|
assert_eq!(input.mode.as_deref(), Some("mergePatch"));
|
|
assert_eq!(input.value["enabled"], true);
|
|
}
|
|
|
|
#[test]
|
|
fn dto_plugins_storage_requests_use_stable_camel_case_contract() {
|
|
let get = PluginStorageGetDto {
|
|
plugin_id: "dev.acme.gitgraph".to_owned(),
|
|
key: "helloPlugin.launches".to_owned(),
|
|
};
|
|
assert_eq!(
|
|
serde_json::to_value(&get).unwrap(),
|
|
json!({
|
|
"pluginId": "dev.acme.gitgraph",
|
|
"key": "helloPlugin.launches"
|
|
})
|
|
);
|
|
let input: application::PluginStorageGetInput = get.into();
|
|
assert_eq!(input.plugin_id, "dev.acme.gitgraph");
|
|
assert_eq!(input.key, "helloPlugin.launches");
|
|
|
|
let set = PluginStorageSetDto {
|
|
plugin_id: "dev.acme.gitgraph".to_owned(),
|
|
key: "helloPlugin.enabled".to_owned(),
|
|
value: json!({"enabled": true}),
|
|
};
|
|
assert_eq!(
|
|
serde_json::to_value(&set).unwrap(),
|
|
json!({
|
|
"pluginId": "dev.acme.gitgraph",
|
|
"key": "helloPlugin.enabled",
|
|
"value": {"enabled": true}
|
|
})
|
|
);
|
|
let input: application::PluginStorageSetInput = set.into();
|
|
assert_eq!(input.plugin_id, "dev.acme.gitgraph");
|
|
assert_eq!(input.key, "helloPlugin.enabled");
|
|
assert_eq!(input.value, json!({"enabled": true}));
|
|
}
|
|
|
|
#[test]
|
|
fn dto_plugins_workspace_outputs_use_stable_camel_case_contract() {
|
|
let listing = PluginWorkspaceDirectoryListingDto {
|
|
path: "src".to_owned(),
|
|
entries: vec![application::PluginWorkspaceDirEntry {
|
|
name: "main.rs".to_owned(),
|
|
path: "src/main.rs".to_owned(),
|
|
is_dir: false,
|
|
}],
|
|
};
|
|
let stat = PluginWorkspaceStatDto {
|
|
path: "src/main.rs".to_owned(),
|
|
exists: true,
|
|
is_file: true,
|
|
is_dir: false,
|
|
len: Some(13),
|
|
};
|
|
let structure = PluginProjectStructureDto {
|
|
project_id: Uuid::from_u128(129).to_string(),
|
|
root_path: String::new(),
|
|
entries: vec![application::ProjectStructureEntry {
|
|
path: "Cargo.toml".to_owned(),
|
|
name: "Cargo.toml".to_owned(),
|
|
kind: "file".to_owned(),
|
|
}],
|
|
conventions: vec![application::ProjectConvention {
|
|
id: "rust-cargo".to_owned(),
|
|
marker_path: "Cargo.toml".to_owned(),
|
|
}],
|
|
modules: vec![application::ProjectModule {
|
|
path: String::new(),
|
|
marker_path: "Cargo.toml".to_owned(),
|
|
convention_id: "rust-cargo".to_owned(),
|
|
}],
|
|
truncated: false,
|
|
};
|
|
|
|
assert_eq!(
|
|
serde_json::to_value(&listing).unwrap(),
|
|
json!({
|
|
"path": "src",
|
|
"entries": [{
|
|
"name": "main.rs",
|
|
"path": "src/main.rs",
|
|
"isDir": false
|
|
}]
|
|
})
|
|
);
|
|
assert_eq!(
|
|
serde_json::to_value(&stat).unwrap(),
|
|
json!({
|
|
"path": "src/main.rs",
|
|
"exists": true,
|
|
"isFile": true,
|
|
"isDir": false,
|
|
"len": 13
|
|
})
|
|
);
|
|
assert_eq!(
|
|
serde_json::to_value(&structure).unwrap(),
|
|
json!({
|
|
"projectId": structure.project_id,
|
|
"rootPath": "",
|
|
"entries": [{
|
|
"path": "Cargo.toml",
|
|
"name": "Cargo.toml",
|
|
"kind": "file"
|
|
}],
|
|
"conventions": [{
|
|
"id": "rust-cargo",
|
|
"markerPath": "Cargo.toml"
|
|
}],
|
|
"modules": [{
|
|
"path": "",
|
|
"markerPath": "Cargo.toml",
|
|
"conventionId": "rust-cargo"
|
|
}],
|
|
"truncated": false
|
|
})
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn dto_plugins_command_task_requests_use_stable_camel_case_contract() {
|
|
let project_id = Uuid::from_u128(125).to_string();
|
|
let owner_agent_id = Uuid::from_u128(126).to_string();
|
|
let run = PluginRunCommandDto {
|
|
project_id: project_id.clone(),
|
|
owner_agent_id: owner_agent_id.clone(),
|
|
label: "cargo test".to_owned(),
|
|
command: "cargo".to_owned(),
|
|
args: vec!["test".to_owned(), "-p".to_owned(), "application".to_owned()],
|
|
cwd: Some("crates/application".to_owned()),
|
|
env: vec![("RUST_LOG".to_owned(), "debug".to_owned())],
|
|
record_only: true,
|
|
deadline_ms: Some(1_800_000_000_000),
|
|
};
|
|
|
|
assert_eq!(
|
|
serde_json::to_value(&run).unwrap(),
|
|
json!({
|
|
"projectId": project_id,
|
|
"ownerAgentId": owner_agent_id,
|
|
"label": "cargo test",
|
|
"command": "cargo",
|
|
"args": ["test", "-p", "application"],
|
|
"cwd": "crates/application",
|
|
"env": [["RUST_LOG", "debug"]],
|
|
"recordOnly": true,
|
|
"deadlineMs": 1_800_000_000_000u64
|
|
})
|
|
);
|
|
|
|
let input: application::PluginRunCommandInput = run.into();
|
|
assert_eq!(input.cwd.as_deref(), Some("crates/application"));
|
|
assert_eq!(input.env, vec![("RUST_LOG".to_owned(), "debug".to_owned())]);
|
|
assert!(input.record_only);
|
|
|
|
let status = PluginTaskStatusDto {
|
|
task_id: Uuid::from_u128(127).to_string(),
|
|
};
|
|
assert_eq!(
|
|
serde_json::to_value(&status).unwrap(),
|
|
json!({ "taskId": status.task_id })
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn dto_plugins_toolchain_diagnostic_request_maps_to_application_input() {
|
|
let project_id = Uuid::from_u128(126).to_string();
|
|
let request = PluginToolchainDiagnosticRequestDto {
|
|
project_id: project_id.clone(),
|
|
cwd: Some("crates/backend".to_owned()),
|
|
tools: vec![PluginToolRequirementDto {
|
|
id: "rust".to_owned(),
|
|
executable: "cargo".to_owned(),
|
|
version_args: vec!["--version".to_owned()],
|
|
required: true,
|
|
env: vec![("CARGO_TERM_COLOR".to_owned(), "never".to_owned())],
|
|
}],
|
|
env: vec![PluginEnvRequirementDto {
|
|
name: "RUSTUP_HOME".to_owned(),
|
|
required: false,
|
|
equals: None,
|
|
}],
|
|
files: vec![PluginFileRequirementDto {
|
|
path: "Cargo.toml".to_owned(),
|
|
required: true,
|
|
kind: Some("file".to_owned()),
|
|
}],
|
|
};
|
|
|
|
assert_eq!(
|
|
serde_json::to_value(&request).unwrap(),
|
|
json!({
|
|
"projectId": project_id,
|
|
"cwd": "crates/backend",
|
|
"tools": [{
|
|
"id": "rust",
|
|
"executable": "cargo",
|
|
"versionArgs": ["--version"],
|
|
"required": true,
|
|
"env": [["CARGO_TERM_COLOR", "never"]]
|
|
}],
|
|
"env": [{
|
|
"name": "RUSTUP_HOME",
|
|
"required": false,
|
|
"equals": null
|
|
}],
|
|
"files": [{
|
|
"path": "Cargo.toml",
|
|
"required": true,
|
|
"kind": "file"
|
|
}]
|
|
})
|
|
);
|
|
|
|
let input: application::PluginToolchainDiagnosticInput = request.into();
|
|
assert_eq!(input.cwd.as_deref(), Some("crates/backend"));
|
|
assert_eq!(input.tools[0].id, "rust");
|
|
assert_eq!(input.tools[0].env[0].0, "CARGO_TERM_COLOR");
|
|
assert_eq!(input.env[0].name, "RUSTUP_HOME");
|
|
assert_eq!(input.files[0].kind.as_deref(), Some("file"));
|
|
}
|
|
|
|
#[test]
|
|
fn dto_plugins_event_subscription_requests_use_stable_camel_case_contract() {
|
|
let project_id = Uuid::from_u128(127).to_string();
|
|
let subscribe = PluginEventSubscribeDto {
|
|
project_id: project_id.clone(),
|
|
event_types: vec![
|
|
"workspaceFileChanged".to_owned(),
|
|
"backgroundTaskChanged".to_owned(),
|
|
],
|
|
capacity: Some(250),
|
|
};
|
|
assert_eq!(
|
|
serde_json::to_value(&subscribe).unwrap(),
|
|
json!({
|
|
"projectId": project_id,
|
|
"eventTypes": ["workspaceFileChanged", "backgroundTaskChanged"],
|
|
"capacity": 250
|
|
})
|
|
);
|
|
let input: application::PluginEventSubscribeInput = subscribe.into();
|
|
assert_eq!(
|
|
input.event_types,
|
|
vec![
|
|
"workspaceFileChanged".to_owned(),
|
|
"backgroundTaskChanged".to_owned()
|
|
]
|
|
);
|
|
assert_eq!(input.capacity, Some(250));
|
|
|
|
let poll = PluginEventPollDto {
|
|
subscription_id: Uuid::from_u128(128).to_string(),
|
|
max_events: Some(50),
|
|
};
|
|
assert_eq!(
|
|
serde_json::to_value(&poll).unwrap(),
|
|
json!({
|
|
"subscriptionId": poll.subscription_id,
|
|
"maxEvents": 50
|
|
})
|
|
);
|
|
let input: application::PluginEventPollInput = poll.into();
|
|
assert_eq!(input.max_events, Some(50));
|
|
|
|
let unsubscribe = PluginEventUnsubscribeDto {
|
|
subscription_id: Uuid::from_u128(129).to_string(),
|
|
};
|
|
assert_eq!(
|
|
serde_json::to_value(&unsubscribe).unwrap(),
|
|
json!({ "subscriptionId": unsubscribe.subscription_id })
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn background_task_dto_exposes_rendezvous_context_only_for_headless_rendezvous() {
|
|
let project_id = ProjectId::from_uuid(Uuid::from_u128(1));
|
|
let owner = AgentId::from_uuid(Uuid::from_u128(2));
|
|
let requester = AgentId::from_uuid(Uuid::from_u128(3));
|
|
let conversation_id = ConversationId::from_uuid(Uuid::from_u128(4));
|
|
let task = BackgroundTask::new(
|
|
TaskId::from_uuid(Uuid::from_u128(5)),
|
|
project_id,
|
|
owner,
|
|
BackgroundTaskKind::HeadlessRendezvous {
|
|
requester_agent_id: Some(requester),
|
|
target_agent_id: owner,
|
|
ticket_id: TicketId::from_uuid(Uuid::from_u128(6)),
|
|
conversation_id,
|
|
},
|
|
domain::BackgroundTaskWakePolicy::RecordOnly,
|
|
100,
|
|
None,
|
|
)
|
|
.unwrap();
|
|
|
|
let json = serde_json::to_value(BackgroundTaskDto::from(task)).unwrap();
|
|
|
|
assert_eq!(json["kind"], "headlessRendezvous");
|
|
assert_eq!(json["requesterAgentId"], requester.to_string());
|
|
assert_eq!(json["targetAgentId"], owner.to_string());
|
|
assert_eq!(json["conversationId"], conversation_id.to_string());
|
|
|
|
let command = BackgroundTask::new(
|
|
TaskId::from_uuid(Uuid::from_u128(7)),
|
|
project_id,
|
|
owner,
|
|
BackgroundTaskKind::Command {
|
|
label: "cargo test".to_owned(),
|
|
},
|
|
domain::BackgroundTaskWakePolicy::RecordOnly,
|
|
100,
|
|
None,
|
|
)
|
|
.unwrap();
|
|
let json = serde_json::to_value(BackgroundTaskDto::from(command)).unwrap();
|
|
assert!(json.get("requesterAgentId").is_none());
|
|
assert!(json.get("targetAgentId").is_none());
|
|
assert!(json.get("conversationId").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn agent_background_task_state_dto_exposes_rendezvous_context() {
|
|
let requester = AgentId::from_uuid(Uuid::from_u128(11));
|
|
let target = AgentId::from_uuid(Uuid::from_u128(12));
|
|
let conversation_id = ConversationId::from_uuid(Uuid::from_u128(13));
|
|
let state = AgentBackgroundTaskState {
|
|
task_id: TaskId::from_uuid(Uuid::from_u128(14)),
|
|
kind: BackgroundTaskKindLabel::HeadlessRendezvous,
|
|
state: BackgroundTaskState::Completed,
|
|
exit_code: None,
|
|
summary: Some("ok".to_owned()),
|
|
stdout_tail: None,
|
|
stderr_tail: None,
|
|
requester_agent_id: Some(requester),
|
|
target_agent_id: Some(target),
|
|
conversation_id: Some(conversation_id),
|
|
created_at_ms: 100,
|
|
updated_at_ms: 200,
|
|
};
|
|
|
|
let json = serde_json::to_value(AgentBackgroundTaskStateDto::from(state)).unwrap();
|
|
|
|
assert_eq!(json["requesterAgentId"], requester.to_string());
|
|
assert_eq!(json["targetAgentId"], target.to_string());
|
|
assert_eq!(json["conversationId"], conversation_id.to_string());
|
|
}
|
|
}
|