Files
IdeaSDK/crates/app-tauri/src/commands.rs
Blomios 8fe93d1652 feat(backend): appareils appairés persistants, révocables et code éphémère (#77 B1-B4)
L'appairage ne survivait pas au redémarrage et son code, permanent, était
imprimé sur la sortie standard. Un appareil appairé devient une entité
persistante, nommée et révocable, derrière un code désormais éphémère.

- B1 : port DeviceSessionStore et adapter FsDeviceSessionStore, entités de
  domaine (PairedDevice, DeviceId, SessionTokenHash, DeviceName). Les tokens
  sont hachés en SHA-256 et comparés en temps constant (subtle) : le store
  ne peut pas rejouer une session qu'il a servie. Cookie Max-Age 400 j à
  renouvellement glissant, lastSeenAtMs throttlé.
- B2 : code éphémère en mémoire, TTL 10 min et usage unique, toute
  génération invalidant la précédente. POST /api/pairing-code authentifiée,
  flag --new-code. Le code est retiré du boot et l'eprintln! qui l'imprimait
  est supprimé.
- B3 : endpoints devices (list/rename/revoke/revoke-all/logout), event
  DeviceRevoked et ActiveConnectionRegistry par device_id, fermant sans
  délai les WebSockets d'un appareil révoqué.
- B4 : port PairAttemptLimiter et adapter mémoire, rate-limit par origine et
  global sur horloge injectée, donc testable sans attente réelle.

La normalisation du code passe côté serveur : elle absorbe la dette #76, que
la seule normalisation frontend de #75 ne faisait que masquer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 13:26:56 +02:00

3466 lines
122 KiB
Rust

//! `#[tauri::command]` handlers — the **driving adapters** (frontend → backend).
//!
//! Each handler is a thin shell: deserialise the DTO, call the use case from
//! [`AppState`], map `Result<Output, AppError>` to `Result<ResponseDto,
//! ErrorDto>`. No business logic lives here.
use serde::Serialize;
use tauri::ipc::Channel;
use tauri::{AppHandle, Emitter, Manager, State, WebviewUrl, WebviewWindowBuilder, WindowEvent};
use crate::dto::DismissEmbedderSuggestionRequestDto;
use application::{
AppError, AssignSkillToAgentInput, AttachLiveAgentInput, ChangeAgentProfileInput,
CloseProjectInput, CreateAgentInput, CreateLayoutInput, CreateMemoryInput, CreateSkillInput,
DeleteAgentInput, DeleteEmbedderProfileInput, DeleteLayoutInput, DeleteMemoryInput,
DeleteSkillInput, DeleteTemplateInput, DetectAgentDriftInput, GetMemoryInput,
GetProjectWorkStateInput, GitBranchesInput, GitCheckoutInput, GitCommitInput, GitGraphInput,
GitInitInput, GitLogInput, GitStagePathInput, GitStatusInput, InspectConversationInput,
LaunchAgentInput, ListAgentsInput, ListDevicesInput, ListLayoutsInput, ListMemoriesInput,
ListResumableAgentsInput, ListSkillsInput, LiveSessions, LoadLayoutInput, McpRuntime,
MutateLayoutInput, OpenProjectInput, ReadAgentContextInput, ReadConversationPageInput,
ReadMemoryIndexInput, ReadProjectContextInput, RecallMemoryInput, ReconcileLayoutsInput,
ReconcileLiveStateInput, RenameDeviceInput, RenameLayoutInput, ResolveAgentPermissionsInput,
ResolveMemoryLinksInput, RevokeDeviceInput, RotateConversationLogInput, SetActiveLayoutInput,
SnapshotRunningAgentsInput, StopLiveAgentInput, SyncAgentWithTemplateInput,
UnassignSkillFromAgentInput, UpdateAgentContextInput, UpdateAgentPermissionsInput,
UpdateMemoryInput, UpdateProjectContextInput, UpdateProjectPermissionsInput, UpdateSkillInput,
};
use domain::ports::ModelServerRuntime;
use domain::ports::PtyHandle;
use crate::dto::{
model_server_config_domain, parse_agent_id, parse_close_terminal, parse_delete_profile,
parse_layout_id, parse_memory_slug, parse_model_server_id, parse_node_id, parse_profile_id,
parse_project_id, parse_session_id, parse_skill_id, parse_task_id, parse_template_id,
parse_ticket_id, save_model_server_input, AgentDriftListDto, AgentDto, AgentListDto,
AssignSkillRequestDto, AttachLiveAgentRequestDto, AttachLiveAgentResponseDto,
BackgroundTaskDto, ChangeAgentProfileDto, ChangeAgentProfileRequestDto,
CloneOpenCodeProfileFromSeedRequestDto, ConfigureProfilesRequestDto, ConversationDetailsDto,
CreateAgentFromTemplateRequestDto, CreateAgentRequestDto, CreateLayoutRequestDto,
CreateLayoutResultDto, CreateMemoryRequestDto, CreateProjectRequestDto, CreateSkillRequestDto,
CreateTemplateRequestDto, DeleteLayoutRequestDto, DeleteLayoutResultDto,
DeliveredDelegationRequestDto, DetectProfilesRequestDto, DetectProfilesResponseDto,
EffectivePermissionsDto, EmbedderEnginesDto, EmbedderProfileDto, EmbedderProfileListDto,
ErrorDto, FirstRunStateDto, FrontAttachedRequestDto, GitBranchesDto, GitCheckoutRequestDto,
GitCommitDto, GitCommitListDto, GitCommitRequestDto, GitStageRequestDto, GitStatusListDto,
GraphCommitListDto, HealthRequestDto, HealthResponseDto, InspectConversationRequestDto,
InterruptAgentRequestDto, LaunchAgentRequestDto, LayoutDto, LayoutOperationDto, ListLayoutsDto,
LiveAgentListDto, MemoryDto, MemoryIndexDto, MemoryLinksDto, MemoryListDto,
ModelServerConfigDto, ModelServerConfigListDto, OpenTerminalRequestDto,
PreviewModelServerCommandDto, ProfileDto, ProfileListDto, ProjectDto, ProjectListDto,
ProjectPermissionsDto, ProjectWorkStateDto, ReadAgentContextResponseDto,
ReadConversationPageRequestDto, ReattachChatDto, ReattachResultDto, RecallMemoryRequestDto,
RenameLayoutRequestDto, ReplyChunk, ResizeTerminalRequestDto,
ResolveAgentPermissionsRequestDto, ResumableAgentListDto, SaveEmbedderProfileRequestDto,
SaveModelServerRequestDto, SaveProfileRequestDto, SetActiveLayoutRequestDto,
SetActiveLayoutResultDto, SkillDto, SkillListDto, StopLiveAgentRequestDto,
StopLiveAgentResponseDto, SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto,
TemplateListDto, TerminalClosedDto, TerminalSessionDto, TurnPageDto, UnassignSkillRequestDto,
UpdateAgentContextRequestDto, UpdateAgentPermissionsRequestDto, UpdateMemoryRequestDto,
UpdateProjectContextRequestDto, UpdateProjectPermissionsRequestDto, UpdateSkillRequestDto,
UpdateTemplateRequestDto, WriteTerminalRequestDto,
};
use crate::embedded_server::{
EmbeddedServerStatusDto, ServerExposurePreviewDto, ServerExposureSettingsDto,
};
use crate::pty::{PtyBridge, PtyChunk};
use crate::state::{AppState, FocusedProjectDto};
use domain::{DeviceId, SkillRef, SkillScope};
use uuid::Uuid;
/// `health` — trivial command validating the full IPC pipeline
/// (frontend gateway → invoke → command → use case → ports → event relay).
///
/// # Errors
/// Returns an [`ErrorDto`] if the use case fails.
#[tauri::command]
pub fn health(
request: Option<HealthRequestDto>,
state: State<'_, AppState>,
) -> Result<HealthResponseDto, ErrorDto> {
let input = request.unwrap_or_default().into();
state
.health
.execute(input)
.map(HealthResponseDto::from)
.map_err(ErrorDto::from)
}
/// `get_server_exposure_settings` — read persisted embedded-server exposure settings.
///
/// # Errors
/// Returns an [`ErrorDto`] when persisted settings are invalid.
#[tauri::command]
pub fn get_server_exposure_settings(
state: State<'_, AppState>,
) -> Result<ServerExposureSettingsDto, ErrorDto> {
state.embedded_server.get_settings()
}
/// `save_server_exposure_settings` — validate and persist embedded-server exposure settings.
///
/// # Errors
/// Returns an [`ErrorDto`] when settings are invalid or cannot be written.
#[tauri::command]
pub fn save_server_exposure_settings(
settings: ServerExposureSettingsDto,
state: State<'_, AppState>,
) -> Result<(), ErrorDto> {
state.embedded_server.save_settings(settings)
}
/// `preview_server_exposure_settings` — derive LAN candidates and upstream URL.
///
/// # Errors
/// Returns an [`ErrorDto`] when settings are invalid.
#[tauri::command]
pub fn preview_server_exposure_settings(
settings: ServerExposureSettingsDto,
state: State<'_, AppState>,
) -> Result<ServerExposurePreviewDto, ErrorDto> {
state.embedded_server.preview(settings)
}
/// `embedded_server_status` — return the current embedded server status.
#[tauri::command]
pub fn embedded_server_status(state: State<'_, AppState>) -> EmbeddedServerStatusDto {
state.embedded_server.status()
}
/// `embedded_server_start` — start the embedded server with the shared backend core.
///
/// # Errors
/// Returns an [`ErrorDto`] when settings are invalid or the listener cannot start.
#[tauri::command]
pub async fn embedded_server_start(
state: State<'_, AppState>,
) -> Result<EmbeddedServerStatusDto, ErrorDto> {
state.embedded_server.start(state.core()).await
}
/// `embedded_server_stop` — stop the embedded server.
///
/// # Errors
/// Returns an [`ErrorDto`] when stopping the listener fails.
#[tauri::command]
pub async fn embedded_server_stop(
state: State<'_, AppState>,
) -> Result<EmbeddedServerStatusDto, ErrorDto> {
state.embedded_server.stop().await
}
/// `embedded_server_generate_pairing_code` — generate a desktop-owned ephemeral code.
///
/// # Errors
/// Returns an [`ErrorDto`] when the embedded server is not running.
#[tauri::command]
pub fn embedded_server_generate_pairing_code(
state: State<'_, AppState>,
) -> Result<web_server::PairingCodeDto, ErrorDto> {
state.embedded_server.generate_pairing_code()
}
/// Device row exposed to the desktop settings surface.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DeviceDto {
/// Device id.
pub device_id: String,
/// User-facing name.
pub name: String,
/// Pairing timestamp as epoch milliseconds.
pub paired_at_ms: u64,
/// Last successful access timestamp as epoch milliseconds.
pub last_seen_at_ms: u64,
/// Whether this is the current authenticated web device.
pub is_current_device: bool,
}
/// List response for paired devices.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DeviceListDto {
/// Paired devices.
pub devices: Vec<DeviceDto>,
}
/// Rename request for paired devices.
#[derive(Debug, Clone, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RenameDeviceRequestDto {
/// Device id.
pub device_id: String,
/// New name.
pub name: String,
}
/// Revoke request for one paired device.
#[derive(Debug, Clone, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RevokeDeviceRequestDto {
/// Device id.
pub device_id: String,
}
fn parse_device_id(raw: &str) -> Result<DeviceId, ErrorDto> {
Uuid::parse_str(raw)
.map(DeviceId::from_uuid)
.map_err(|_| ErrorDto {
code: "INVALID".to_owned(),
message: "invalid device id".to_owned(),
})
}
/// `list_devices` — list paired devices through the desktop composition root.
///
/// # Errors
/// Returns an [`ErrorDto`] when the device store cannot be read.
#[tauri::command]
pub async fn list_devices(state: State<'_, AppState>) -> Result<DeviceListDto, ErrorDto> {
let output = state
.list_devices
.execute(ListDevicesInput {
current_device_id: None,
})
.await
.map_err(ErrorDto::from)?;
Ok(DeviceListDto {
devices: output
.devices
.into_iter()
.map(|device| DeviceDto {
device_id: device.device_id.to_string(),
name: device.name,
paired_at_ms: device.paired_at_ms,
last_seen_at_ms: device.last_seen_at_ms,
is_current_device: device.is_current_device,
})
.collect(),
})
}
/// `create_pairing_code` — generate an ephemeral pairing code via the embedded server state.
///
/// # Errors
/// Returns an [`ErrorDto`] when the embedded server is not running.
#[tauri::command]
pub fn create_pairing_code(
state: State<'_, AppState>,
) -> Result<web_server::PairingCodeDto, ErrorDto> {
state.embedded_server.generate_pairing_code()
}
/// `rename_device` — rename a paired device.
///
/// # Errors
/// Returns an [`ErrorDto`] for invalid input or store failures.
#[tauri::command]
pub async fn rename_device(
request: RenameDeviceRequestDto,
state: State<'_, AppState>,
) -> Result<(), ErrorDto> {
state
.rename_device
.execute(RenameDeviceInput {
device_id: parse_device_id(&request.device_id)?,
name: request.name,
})
.await
.map_err(ErrorDto::from)
}
/// `revoke_device` — revoke one paired device.
///
/// # Errors
/// Returns an [`ErrorDto`] for invalid input or store failures.
#[tauri::command]
pub async fn revoke_device(
request: RevokeDeviceRequestDto,
state: State<'_, AppState>,
) -> Result<(), ErrorDto> {
state
.revoke_device
.execute(RevokeDeviceInput {
device_id: parse_device_id(&request.device_id)?,
})
.await
.map_err(ErrorDto::from)
}
/// `revoke_all_devices` — revoke every paired device.
///
/// # Errors
/// Returns an [`ErrorDto`] when the device store cannot be rewritten.
#[tauri::command]
pub async fn revoke_all_devices(state: State<'_, AppState>) -> Result<(), ErrorDto> {
state
.revoke_all_devices
.execute()
.await
.map_err(ErrorDto::from)
}
/// `create_project` — create a project from a root: init `.ideai/`, register it.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a bad root/name or a duplicate
/// `(remote, root)`, `FILESYSTEM`/`STORE` on I/O failure).
#[tauri::command]
pub async fn create_project(
request: CreateProjectRequestDto,
state: State<'_, AppState>,
) -> Result<ProjectDto, ErrorDto> {
let output = state
.create_project
.execute(request.into())
.await
.map_err(ErrorDto::from)?;
// Start tailing this project's `.ideai/requests/` tree so an orchestrator
// agent can delegate agent/skill creation to IdeA (§14.3).
state.ensure_orchestrator_watch(&output.project);
Ok(ProjectDto::from(output))
}
/// `open_project` — load a project and its `.ideai/` meta/manifest.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if the
/// project is unknown, `STORE` on registry I/O failure).
#[tauri::command]
pub async fn open_project(
project_id: String,
state: State<'_, AppState>,
) -> Result<ProjectDto, ErrorDto> {
open_project_for_adapter(project_id, &state).await
}
/// Shared `open_project` adapter body used by desktop IPC and the HTTP server.
///
/// Keeps the non-presentation side effects attached to project opening in one
/// place while B3 adds a second driving adapter.
pub(crate) async fn open_project_for_adapter(
project_id: String,
state: &AppState,
) -> Result<ProjectDto, ErrorDto> {
let id = parse_project_id(&project_id)?;
let output = state
.open_project
.execute(OpenProjectInput { project_id: id })
.await
.map_err(ErrorDto::from)?;
// R0c (§3.4 « Trou C ») : dé-doublonne les `layouts.json` portant plusieurs
// feuilles sur le même agent AVANT toute reprise (`list_resumable_agents`
// relit la version persistée), de sorte qu'on ne propose / ne relance qu'une
// session par agent. Idempotent (no-op sans doublon) et best-effort : un échec
// ne doit pas bloquer l'ouverture.
let _ = state
.reconcile_layouts
.execute(ReconcileLayoutsInput { project_id: id })
.await;
// Réconcilie les lignes de live-state fantômes : un crash ne déclenche jamais
// `close_project`, donc l'ouverture est le seul filet fiable pour repasser en
// `idle` les agents `working`/`waiting`/`blocked` dont la session est morte.
// Acte système (appelle le port live-state directement, jamais via la surface
// MCP self-only). Best-effort : un échec ne bloque pas l'ouverture. Tourne
// AVANT toute relance d'agent (toute reprise réécrit ensuite en LWW).
let _ = state
.reconcile_live_state
.execute(ReconcileLiveStateInput { project_id: id })
.await;
// Réconcilie les tâches de fond persistées au même point de boot que le
// live-state : les Running sans handle vivant deviennent terminales, les
// complétions WakeOwner non livrées repassent dans l'inbox unifiée.
let _ = state.reconcile_background_tasks.execute(id).await;
// (Re)start the orchestrator watcher for this project (idempotent, §14.3).
state.ensure_orchestrator_watch(&output.project);
state.reconcile_claude_run_dirs(&output.project).await;
Ok(ProjectDto::from(output))
}
/// `close_project` — persist state and release resources for a project.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `STORE` on failure).
#[tauri::command]
pub async fn close_project(project_id: String, state: State<'_, AppState>) -> Result<(), ErrorDto> {
let id = parse_project_id(&project_id)?;
// T5: freeze `agent_was_running` on every agent leaf BEFORE any PTY release,
// reading the live registry as it stands now. Best-effort: a snapshot failure
// must not block the close.
let _ = state
.snapshot_running_agents
.execute(SnapshotRunningAgentsInput { project_id: id })
.await;
// Stop tailing this project's `.ideai/requests/` tree (§14.3).
state.stop_orchestrator_watch(&id);
state
.close_project
.execute(CloseProjectInput {
project_id: id,
// L2 has no UI-side workspace mutations to persist yet.
workspace: None,
})
.await
.map(|_| ())
.map_err(ErrorDto::from)
}
/// `list_projects` — list the projects known to the registry.
///
/// # Errors
/// Returns an [`ErrorDto`] (`STORE` on registry I/O failure).
#[tauri::command]
pub async fn list_projects(state: State<'_, AppState>) -> Result<ProjectListDto, ErrorDto> {
state
.list_projects
.execute()
.await
.map(ProjectListDto::from)
.map_err(ErrorDto::from)
}
/// `read_project_context` — read `.ideai/CONTEXT.md` for a project.
///
/// Missing context is returned as an empty string so a project whose `.ideai/`
/// was deleted can still open cleanly.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if the
/// project is unknown, `FILESYSTEM`/`STORE` on read or UTF-8 failure).
#[tauri::command]
pub async fn read_project_context(
project_id: String,
state: State<'_, AppState>,
) -> Result<String, ErrorDto> {
let project = resolve_project(&project_id, &state).await?;
state
.read_project_context
.execute(ReadProjectContextInput { project })
.await
.map(|out| out.content)
.map_err(ErrorDto::from)
}
/// `update_project_context` — overwrite `.ideai/CONTEXT.md` for a project.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if the
/// project is unknown, `FILESYSTEM` on write failure).
#[tauri::command]
pub async fn update_project_context(
request: UpdateProjectContextRequestDto,
state: State<'_, AppState>,
) -> Result<(), ErrorDto> {
let project = resolve_project(&request.project_id, &state).await?;
state
.update_project_context
.execute(UpdateProjectContextInput {
project,
content: request.content,
})
.await
.map_err(ErrorDto::from)
}
/// `get_project_permissions` — read `.ideai/permissions.json`.
///
/// # Errors
/// Returns an [`ErrorDto`] on invalid project id or store failure.
#[tauri::command]
pub async fn get_project_permissions(
project_id: String,
state: State<'_, AppState>,
) -> Result<ProjectPermissionsDto, ErrorDto> {
let project = resolve_project(&project_id, &state).await?;
state
.get_project_permissions
.execute(application::GetProjectPermissionsInput { project })
.await
.map(|out| ProjectPermissionsDto(out.permissions))
.map_err(ErrorDto::from)
}
/// `update_project_permissions` — replace project default permissions.
///
/// # Errors
/// Returns an [`ErrorDto`] on invalid project id or store failure.
#[tauri::command]
pub async fn update_project_permissions(
request: UpdateProjectPermissionsRequestDto,
state: State<'_, AppState>,
) -> Result<ProjectPermissionsDto, ErrorDto> {
let project = resolve_project(&request.project_id, &state).await?;
state
.update_project_permissions
.execute(UpdateProjectPermissionsInput {
project,
permissions: request.permissions,
})
.await
.map(|out| ProjectPermissionsDto(out.permissions))
.map_err(ErrorDto::from)
}
/// `update_agent_permissions` — replace or remove one agent override.
///
/// # Errors
/// Returns an [`ErrorDto`] on invalid ids or store failure.
#[tauri::command]
pub async fn update_agent_permissions(
request: UpdateAgentPermissionsRequestDto,
state: State<'_, AppState>,
) -> Result<ProjectPermissionsDto, ErrorDto> {
let project = resolve_project(&request.project_id, &state).await?;
let agent_id = parse_agent_id(&request.agent_id)?;
state
.update_agent_permissions
.execute(UpdateAgentPermissionsInput {
project,
agent_id,
permissions: request.permissions,
})
.await
.map(|out| ProjectPermissionsDto(out.permissions))
.map_err(ErrorDto::from)
}
/// `resolve_agent_permissions` — resolve project defaults plus agent override.
///
/// # Errors
/// Returns an [`ErrorDto`] on invalid ids or store failure.
#[tauri::command]
pub async fn resolve_agent_permissions(
request: ResolveAgentPermissionsRequestDto,
state: State<'_, AppState>,
) -> Result<Option<EffectivePermissionsDto>, ErrorDto> {
let project = resolve_project(&request.project_id, &state).await?;
let agent_id = parse_agent_id(&request.agent_id)?;
state
.resolve_agent_permissions
.execute(ResolveAgentPermissionsInput { project, agent_id })
.await
.map(|out| out.effective.map(EffectivePermissionsDto))
.map_err(ErrorDto::from)
}
// ---------------------------------------------------------------------------
// Terminals (L3)
// ---------------------------------------------------------------------------
/// `open_terminal` — spawn a PTY and wire its byte stream to the frontend.
///
/// The frontend passes a per-session [`Channel`] (xterm's output sink). We:
/// 1. run [`application::OpenTerminal`] (spawn the PTY, register the session),
/// 2. register the channel in the [`PtyBridge`] keyed by the new session id,
/// 3. start a pump that drains the PTY's blocking output stream and forwards
/// each chunk through the bridge to that channel.
///
/// Returns the [`TerminalSessionDto`] (its `sessionId` is what `write`/`resize`/
/// `close` reference).
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a bad cwd/size, `PROCESS` if the PTY
/// fails to spawn or its output cannot be subscribed).
#[tauri::command]
pub async fn open_terminal(
request: OpenTerminalRequestDto,
on_output: Channel<PtyChunk>,
state: State<'_, AppState>,
) -> Result<TerminalSessionDto, ErrorDto> {
let output = state
.open_terminal
.execute(request.into())
.await
.map_err(ErrorDto::from)?;
let session_id = output.session.id;
// (2) Register the xterm output channel for this session.
let gen = state.pty_bridge.register(session_id, on_output);
// (3) Subscribe to the PTY's byte stream and pump it to the channel. The
// stream is a blocking iterator, so it runs on a dedicated OS thread; it
// ends when the PTY hits EOF (process exit) or this attach is superseded.
let handle = PtyHandle { session_id };
match state.pty_port.subscribe_output(&handle) {
Ok(stream) => {
let bridge: std::sync::Arc<PtyBridge> = std::sync::Arc::clone(&state.pty_bridge);
std::thread::spawn(move || {
for chunk in stream {
if !bridge.send_output(&session_id, chunk) {
break;
}
}
// Stream ended: drop the channel only if still ours (a re-attach
// may have superseded this generation — don't tear down its channel).
bridge.unregister_if(&session_id, gen);
});
}
Err(e) => {
state.pty_bridge.unregister(&session_id);
return Err(ErrorDto::from(application::AppError::from(e)));
}
}
Ok(TerminalSessionDto::from(output))
}
/// `write_terminal` — forward bytes (xterm keystrokes) to a live PTY.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if the
/// session is unknown, `PROCESS` on PTY I/O failure).
#[tauri::command]
pub fn write_terminal(
request: WriteTerminalRequestDto,
state: State<'_, AppState>,
) -> Result<(), ErrorDto> {
let input = request.into_input()?;
let session_id = input.session_id;
let bytes = input.data.len();
let control = describe_terminal_write(&input.data);
application::diag!(
"[pty-write] write_terminal start: session={session_id} bytes={bytes} control={control}"
);
match state.write_terminal.execute(input) {
Ok(()) => {
application::diag!(
"[pty-write] write_terminal ok: session={session_id} bytes={bytes} control={control}"
);
Ok(())
}
Err(e) => {
application::diag!(
"[pty-write] write_terminal failed: session={session_id} bytes={bytes} \
control={control} error={e}"
);
Err(ErrorDto::from(e))
}
}
}
fn describe_terminal_write(data: &[u8]) -> String {
if data.is_empty() {
return "<empty>".to_owned();
}
if data.len() > 16 {
return "<bulk>".to_owned();
}
data.iter()
.map(|byte| match *byte {
b'\r' => "\\r".to_owned(),
b'\n' => "\\n".to_owned(),
0x7f => "\\x7f".to_owned(),
byte if byte < 0x20 => format!("\\x{byte:02x}"),
byte => char::from(byte).to_string(),
})
.collect::<Vec<_>>()
.join("")
}
/// `resize_terminal` — resize a live PTY.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a malformed id/size, `NOT_FOUND` if the
/// session is unknown, `PROCESS` on failure).
#[tauri::command]
pub fn resize_terminal(
request: ResizeTerminalRequestDto,
state: State<'_, AppState>,
) -> Result<(), ErrorDto> {
let input = request.into_input()?;
state.resize_terminal.execute(input).map_err(ErrorDto::from)
}
/// `close_terminal` — kill a live PTY and tear down its channel.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if the
/// session is unknown, `PROCESS` if the kill fails).
#[tauri::command]
pub async fn close_terminal(
session_id: String,
state: State<'_, AppState>,
) -> Result<TerminalClosedDto, ErrorDto> {
let input = parse_close_terminal(&session_id)?;
let sid = parse_session_id(&session_id)?;
let result = state
.close_terminal
.execute(input)
.await
.map(TerminalClosedDto::from)
.map_err(ErrorDto::from);
// Tear down the channel regardless of kill outcome.
state.pty_bridge.unregister(&sid);
result
}
/// `reattach_terminal` — re-bind a view to a **still-living** PTY without
/// re-spawning it.
///
/// Navigation (switching layout/tab) tears the xterm view down but must NOT kill
/// the backend PTY (the AI keeps running). When the view comes back it calls this
/// command, which:
/// 1. reads the session's retained **scrollback** so the terminal can repaint,
/// 2. registers the new per-session [`Channel`] in the [`PtyBridge`],
/// 3. starts a fresh output pump subscribed to the live PTY (re-subscribable
/// broadcast), so new bytes flow to the new channel.
///
/// Returns the scrollback bytes; the frontend writes them into xterm first, then
/// receives subsequent output over `on_output`.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND`/`PROCESS`
/// if the session is no longer alive).
#[tauri::command]
pub fn reattach_terminal(
session_id: String,
on_output: Channel<PtyChunk>,
state: State<'_, AppState>,
) -> Result<ReattachResultDto, ErrorDto> {
let sid = parse_session_id(&session_id)?;
let handle = PtyHandle { session_id: sid };
// (1) Snapshot the scrollback. A NotFound here means the PTY is gone (was
// explicitly closed or exited) — surfaced as an error so the caller falls
// back to opening a fresh terminal.
let scrollback = state
.pty_port
.scrollback(&handle)
.map_err(|e| ErrorDto::from(AppError::from(e)))?;
// (2) Register the new output channel for this session, replacing any stale
// one from a previous attach (and bumping the generation).
let gen = state.pty_bridge.register(sid, on_output);
// (3) Subscribe afresh to the live byte stream and pump it to the channel.
// The fresh subscription supersedes the previous attach's, so its pump thread
// ends and stops double-delivering this session's bytes.
match state.pty_port.subscribe_output(&handle) {
Ok(stream) => {
let bridge: std::sync::Arc<PtyBridge> = std::sync::Arc::clone(&state.pty_bridge);
std::thread::spawn(move || {
for chunk in stream {
if !bridge.send_output(&sid, chunk) {
break;
}
}
bridge.unregister_if(&sid, gen);
});
}
Err(e) => {
state.pty_bridge.unregister(&sid);
return Err(ErrorDto::from(AppError::from(e)));
}
}
Ok(ReattachResultDto {
session_id,
scrollback,
})
}
// ---------------------------------------------------------------------------
// Layout (L4)
// ---------------------------------------------------------------------------
/// `load_layout` — read a project's named layout (the active one when `layout_id`
/// is omitted).
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if the
/// project or layout is unknown, `STORE` on registry I/O failure).
#[tauri::command]
pub async fn load_layout(
project_id: String,
layout_id: Option<String>,
state: State<'_, AppState>,
) -> Result<LayoutDto, ErrorDto> {
let id = parse_project_id(&project_id)?;
let lid = layout_id.as_deref().map(parse_layout_id).transpose()?;
state
.load_layout
.execute(LoadLayoutInput {
project_id: id,
layout_id: lid,
})
.await
.map(LayoutDto::from)
.map_err(ErrorDto::from)
}
/// `mutate_layout` — apply a split/merge/resize/move/setSession/setCellAgent
/// operation, persist the result and announce `LayoutChanged`.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a malformed id/operation or an invariant
/// violation, `NOT_FOUND` for an unknown project/node, `FILESYSTEM`/`STORE` on I/O
/// failure).
#[tauri::command]
pub async fn mutate_layout(
project_id: String,
layout_id: Option<String>,
operation: LayoutOperationDto,
state: State<'_, AppState>,
) -> Result<LayoutDto, ErrorDto> {
let id = parse_project_id(&project_id)?;
let lid = layout_id.as_deref().map(parse_layout_id).transpose()?;
let operation = operation.into_operation()?;
state
.mutate_layout
.execute(MutateLayoutInput {
project_id: id,
layout_id: lid,
operation,
})
.await
.map(LayoutDto::from)
.map_err(ErrorDto::from)
}
// ---------------------------------------------------------------------------
// Named-layout management (#4)
// ---------------------------------------------------------------------------
/// `list_layouts` — list all named layouts of a project and the active one.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if the
/// project is unknown, `STORE`/`FILESYSTEM` on I/O failure).
#[tauri::command]
pub async fn list_layouts(
project_id: String,
state: State<'_, AppState>,
) -> Result<ListLayoutsDto, ErrorDto> {
let id = parse_project_id(&project_id)?;
state
.list_layouts
.execute(ListLayoutsInput { project_id: id })
.await
.map(ListLayoutsDto::from)
.map_err(ErrorDto::from)
}
/// `create_layout` — create a new empty named layout and make it active.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for empty name or malformed id, `NOT_FOUND`
/// if the project is unknown, `STORE`/`FILESYSTEM` on I/O failure).
#[tauri::command]
pub async fn create_layout(
request: CreateLayoutRequestDto,
state: State<'_, AppState>,
) -> Result<CreateLayoutResultDto, ErrorDto> {
let project_id = parse_project_id(&request.project_id)?;
let kind = request.parse_kind()?;
state
.create_layout
.execute(CreateLayoutInput {
project_id,
name: request.name,
kind,
})
.await
.map(CreateLayoutResultDto::from)
.map_err(ErrorDto::from)
}
/// `rename_layout` — rename a named layout.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for empty name or malformed id, `NOT_FOUND`
/// if the project or layout is unknown).
#[tauri::command]
pub async fn rename_layout(
request: RenameLayoutRequestDto,
state: State<'_, AppState>,
) -> Result<(), ErrorDto> {
let project_id = parse_project_id(&request.project_id)?;
let layout_id = parse_layout_id(&request.layout_id)?;
state
.rename_layout
.execute(RenameLayoutInput {
project_id,
layout_id,
name: request.name,
})
.await
.map_err(ErrorDto::from)
}
/// `delete_layout` — delete a named layout (cannot be the last one).
///
/// Returns the active layout id after the deletion.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for malformed id or last layout attempt,
/// `NOT_FOUND` if the project or layout is unknown).
#[tauri::command]
pub async fn delete_layout(
request: DeleteLayoutRequestDto,
state: State<'_, AppState>,
) -> Result<DeleteLayoutResultDto, ErrorDto> {
let project_id = parse_project_id(&request.project_id)?;
let layout_id = parse_layout_id(&request.layout_id)?;
state
.delete_layout
.execute(DeleteLayoutInput {
project_id,
layout_id,
})
.await
.map(DeleteLayoutResultDto::from)
.map_err(ErrorDto::from)
}
/// `set_active_layout` — switch the active named layout of a project.
///
/// Self-healing: a stale `layout_id` does not error; the active layout is left
/// unchanged and the actually-active id is returned (authoritative).
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for malformed id) or a persistence error.
#[tauri::command]
pub async fn set_active_layout(
request: SetActiveLayoutRequestDto,
state: State<'_, AppState>,
) -> Result<SetActiveLayoutResultDto, ErrorDto> {
let project_id = parse_project_id(&request.project_id)?;
let layout_id = parse_layout_id(&request.layout_id)?;
state
.set_active_layout
.execute(SetActiveLayoutInput {
project_id,
layout_id,
})
.await
.map(SetActiveLayoutResultDto::from)
.map_err(ErrorDto::from)
}
// ---------------------------------------------------------------------------
// Profiles & first-run (L5)
// ---------------------------------------------------------------------------
/// `first_run_state` — whether the first-run wizard should show (no
/// `profiles.json` yet) plus the pre-filled reference catalogue to seed it.
///
/// # Errors
/// Returns an [`ErrorDto`] (`STORE` on profiles I/O failure).
#[tauri::command]
pub async fn first_run_state(state: State<'_, AppState>) -> Result<FirstRunStateDto, ErrorDto> {
state
.first_run_state
.execute()
.await
.map(FirstRunStateDto::from)
.map_err(ErrorDto::from)
}
/// `reference_profiles` — the pre-filled, editable reference catalogue offered to
/// agent creation/selection.
///
/// Restricted to the **selectable** profiles (§17.3, lot D7): only profiles
/// drivable in structured mode (today Claude + Codex) are returned. Gemini/Aider
/// stay in the catalogue data but are not proposed, and there is no custom-profile
/// entry. Persistence/editing of pre-existing (legacy) profiles is unaffected.
///
/// # Errors
/// Returns an [`ErrorDto`] (never in practice; the catalogue is in-memory).
#[tauri::command]
pub async fn reference_profiles(state: State<'_, AppState>) -> Result<ProfileListDto, ErrorDto> {
state
.reference_profiles
.execute()
.await
.map(ProfileListDto::from)
.map_err(ErrorDto::from)
}
/// `detect_profiles` — probe each candidate profile's detection command and
/// report which CLIs are installed (✓/✗).
///
/// # Errors
/// Returns an [`ErrorDto`] (detection failures degrade to `available: false`).
#[tauri::command]
pub async fn detect_profiles(
request: DetectProfilesRequestDto,
state: State<'_, AppState>,
) -> Result<DetectProfilesResponseDto, ErrorDto> {
state
.detect_profiles
.execute(request.into())
.await
.map(DetectProfilesResponseDto::from)
.map_err(ErrorDto::from)
}
/// `list_profiles` — list the configured profiles.
///
/// # Errors
/// Returns an [`ErrorDto`] (`STORE` on profiles I/O failure).
#[tauri::command]
pub async fn list_profiles(state: State<'_, AppState>) -> Result<ProfileListDto, ErrorDto> {
state
.list_profiles
.execute()
.await
.map(ProfileListDto::from)
.map_err(ErrorDto::from)
}
/// `save_profile` — create or replace (by id) a single profile.
///
/// # Errors
/// Returns an [`ErrorDto`] (`STORE` on profiles I/O failure).
#[tauri::command]
pub async fn save_profile(
request: SaveProfileRequestDto,
state: State<'_, AppState>,
) -> Result<ProfileDto, ErrorDto> {
state
.save_profile
.execute(request.into())
.await
.map(ProfileDto::from)
.map_err(ErrorDto::from)
}
/// `clone_opencode_profile_from_seed` — create a new OpenCode profile instance
/// from the canonical `opencode-llamacpp` seed/template.
///
/// # Errors
/// Returns an [`ErrorDto`] (`STORE` on profiles I/O failure, `INVALID` for a
/// blank requested name).
#[tauri::command]
pub async fn clone_opencode_profile_from_seed(
request: CloneOpenCodeProfileFromSeedRequestDto,
state: State<'_, AppState>,
) -> Result<ProfileDto, ErrorDto> {
state
.clone_opencode_profile_from_seed
.execute(request.into())
.await
.map(ProfileDto::from)
.map_err(ErrorDto::from)
}
/// `delete_profile` — delete a profile by id.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if absent,
/// `STORE` on failure).
#[tauri::command]
pub async fn delete_profile(
profile_id: String,
state: State<'_, AppState>,
) -> Result<(), ErrorDto> {
let input = parse_delete_profile(&profile_id)?;
state
.delete_profile
.execute(input)
.await
.map_err(ErrorDto::from)
}
/// `configure_profiles` — persist the batch of chosen/edited profiles, closing
/// the first run.
///
/// The selection surface offered upstream is already restricted to selectable
/// profiles (§17.3, D7), so the wizard sends only structured-drivable profiles
/// and no arbitrary custom command. This command itself stays permissive on
/// purpose: it must keep persisting any profile shape so a project with a
/// pre-existing Gemini/Aider/custom **legacy** profile remains editable and
/// runnable (we restrict creation, not the existing).
///
/// # Errors
/// Returns an [`ErrorDto`] (`STORE` on profiles I/O failure).
#[tauri::command]
pub async fn configure_profiles(
request: ConfigureProfilesRequestDto,
state: State<'_, AppState>,
) -> Result<ProfileListDto, ErrorDto> {
state
.configure_profiles
.execute(request.into())
.await
.map(ProfileListDto::from)
.map_err(ErrorDto::from)
}
/// `list_model_servers` — list configured local model servers.
///
/// # Errors
/// Returns an [`ErrorDto`] on registry failure.
#[tauri::command]
pub async fn list_model_servers(
state: State<'_, AppState>,
) -> Result<ModelServerConfigListDto, ErrorDto> {
state
.list_model_servers
.execute()
.await
.map(ModelServerConfigListDto::from)
.map_err(ErrorDto::from)
}
/// `save_model_server` — upsert a local model server config.
///
/// # Errors
/// Returns an [`ErrorDto`] on registry failure.
#[tauri::command]
pub async fn save_model_server(
request: SaveModelServerRequestDto,
state: State<'_, AppState>,
) -> Result<ModelServerConfigDto, ErrorDto> {
let server_id = parse_model_server_id(&request.config.id)?;
let existing = state
.list_model_servers
.execute()
.await
.map_err(ErrorDto::from)?
.servers
.into_iter()
.find(|config| config.id == server_id);
let input = save_model_server_input(request, existing.as_ref())?;
state
.save_model_server
.execute(input)
.await
.map(ModelServerConfigDto::from)
.map_err(ErrorDto::from)
}
/// `preview_model_server_command` — build the llama.cpp argv without persisting.
///
/// # Errors
/// Returns an [`ErrorDto`] when the DTO or runtime invocation is invalid.
#[tauri::command]
pub fn preview_model_server_command(
config: ModelServerConfigDto,
) -> Result<PreviewModelServerCommandDto, ErrorDto> {
let config = model_server_config_domain(config, None)?;
let argv = infrastructure::LlamaCppRuntime::new()
.build_argv(&config)
.map_err(|err| ErrorDto {
code: "INVALID".to_owned(),
message: err.to_string(),
})?;
Ok(PreviewModelServerCommandDto {
display: display_command(&argv.command, &argv.args),
command: argv.command,
args: argv.args,
})
}
/// `delete_model_server` — delete a local model server config when unused.
///
/// # Errors
/// Returns `model_server_in_use` if any OpenCode profile still references it.
#[tauri::command]
pub async fn delete_model_server(
server_id: String,
state: State<'_, AppState>,
) -> Result<(), ErrorDto> {
let server_id = parse_model_server_id(&server_id)?;
state
.delete_model_server
.execute(application::DeleteModelServerInput { server_id })
.await
.map_err(model_server_command_error)
}
fn model_server_command_error(err: AppError) -> ErrorDto {
match err {
AppError::ModelServer { code, message } => ErrorDto { code, message },
other => ErrorDto::from(other),
}
}
fn display_command(command: &str, args: &[String]) -> String {
std::iter::once(command)
.chain(args.iter().map(String::as_str))
.map(shell_escape)
.collect::<Vec<_>>()
.join(" ")
}
fn shell_escape(value: &str) -> String {
if value.is_empty() {
return "''".to_owned();
}
if value
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '/' | '.' | '_' | '-' | ':' | '='))
{
return value.to_owned();
}
format!("'{}'", value.replace('\'', "'\\''"))
}
// ---------------------------------------------------------------------------
// Embedder profiles & engines (LOT C2 — §14.5.3)
// ---------------------------------------------------------------------------
/// `list_embedder_profiles` — list the configured embedder profiles (empty when
/// none configured ⇒ the default `none` posture).
///
/// # Errors
/// Returns an [`ErrorDto`] (`STORE` on `embedder.json` I/O failure).
#[tauri::command]
pub async fn list_embedder_profiles(
state: State<'_, AppState>,
) -> Result<EmbedderProfileListDto, ErrorDto> {
state
.list_embedder_profiles
.execute()
.await
.map(EmbedderProfileListDto::from)
.map_err(ErrorDto::from)
}
/// `save_embedder_profile` — create or replace (by id) a single embedder profile.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for empty id/name or zero dimension, `STORE`
/// on I/O failure).
#[tauri::command]
pub async fn save_embedder_profile(
request: SaveEmbedderProfileRequestDto,
state: State<'_, AppState>,
) -> Result<EmbedderProfileDto, ErrorDto> {
state
.save_embedder_profile
.execute(request.into())
.await
.map(EmbedderProfileDto::from)
.map_err(ErrorDto::from)
}
/// `delete_embedder_profile` — delete an embedder profile by id.
///
/// # Errors
/// Returns an [`ErrorDto`] (`NOT_FOUND` if absent, `STORE` on I/O failure).
#[tauri::command]
pub async fn delete_embedder_profile(
embedder_id: String,
state: State<'_, AppState>,
) -> Result<(), ErrorDto> {
state
.delete_embedder_profile
.execute(DeleteEmbedderProfileInput { id: embedder_id })
.await
.map_err(ErrorDto::from)
}
/// `describe_embedder_engines` — describe the engines available to the
/// "configure an embedder?" UI: the recommended ONNX catalogue, a best-effort
/// snapshot of the local environment, and which strategies are compiled in.
///
/// # Errors
/// Returns an [`ErrorDto`] — in practice never (the environment probe is best-effort).
#[tauri::command]
pub async fn describe_embedder_engines(
state: State<'_, AppState>,
) -> Result<EmbedderEnginesDto, ErrorDto> {
state
.describe_embedder_engines
.execute()
.await
.map(EmbedderEnginesDto::from)
.map_err(ErrorDto::from)
}
/// `dismiss_embedder_suggestion` — persist the user's response to the one-time
/// embedder suggestion (LOT C3 — §14.5.5): `later` (re-proposable next session) or
/// `never` (silenced for good). Resolves the project root from `project_id`.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if the project
/// is unknown, `STORE` on `.embedder-prompt.json` I/O failure).
#[tauri::command]
pub async fn dismiss_embedder_suggestion(
request: DismissEmbedderSuggestionRequestDto,
state: State<'_, AppState>,
) -> Result<(), ErrorDto> {
let project = resolve_project(&request.project_id, &state).await?;
state
.dismiss_embedder_suggestion
.execute(request.into_input(project.root))
.await
.map_err(ErrorDto::from)
}
// ---------------------------------------------------------------------------
// Agents (L6)
// ---------------------------------------------------------------------------
/// Resolves a [`domain::Project`] by id, mapping `StoreError` → `AppError` → `ErrorDto`.
async fn resolve_project(
project_id: &str,
state: &State<'_, AppState>,
) -> Result<domain::Project, ErrorDto> {
let id = parse_project_id(project_id)?;
state
.project_store
.load_project(id)
.await
.map_err(|e| ErrorDto::from(AppError::from(e)))
}
/// `create_agent` — create a new project agent from scratch.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a bad id/name, `NOT_FOUND` if the
/// project is unknown, `STORE` on I/O failure).
#[tauri::command]
pub async fn create_agent(
request: CreateAgentRequestDto,
state: State<'_, AppState>,
) -> Result<AgentDto, ErrorDto> {
let project = resolve_project(&request.project_id, &state).await?;
let profile_id = parse_profile_id(&request.profile_id)?;
state
.create_agent
.execute(CreateAgentInput {
project,
name: request.name,
profile_id,
initial_content: request.initial_content,
})
.await
.map(AgentDto::from)
.map_err(ErrorDto::from)
}
/// `list_agents` — list the agents of a project.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if the
/// project is unknown, `STORE` on I/O failure).
#[tauri::command]
pub async fn list_agents(
project_id: String,
state: State<'_, AppState>,
) -> Result<AgentListDto, ErrorDto> {
let project = resolve_project(&project_id, &state).await?;
state
.list_agents
.execute(ListAgentsInput { project })
.await
.map(AgentListDto::from)
.map_err(ErrorDto::from)
}
/// `get_project_work_state` — read-only live/busy state for manifest agents.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if the
/// project is unknown, `STORE` on manifest I/O failure).
#[tauri::command]
pub async fn get_project_work_state(
project_id: String,
state: State<'_, AppState>,
) -> Result<ProjectWorkStateDto, ErrorDto> {
let project = resolve_project(&project_id, &state).await?;
state
.get_project_work_state
.execute(GetProjectWorkStateInput { project })
.await
.map(ProjectWorkStateDto::from)
.map_err(ErrorDto::from)
}
/// `read_conversation_page` — human, paginated read of a conversation's **full**
/// transcript (lot LS6). Archive-aware (segments + active), text never truncated.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a malformed project/conversation id,
/// `NOT_FOUND` if the project is unknown, `STORE` on log I/O failure).
#[tauri::command]
pub async fn read_conversation_page(
request: ReadConversationPageRequestDto,
state: State<'_, AppState>,
) -> Result<TurnPageDto, ErrorDto> {
let project = resolve_project(&request.project_id, &state).await?;
let conversation = uuid::Uuid::parse_str(&request.conversation_id)
.map(domain::ConversationId::from_uuid)
.map_err(|_| ErrorDto {
code: "INVALID".to_owned(),
message: format!("invalid conversation id: {}", request.conversation_id),
})?;
let cursor = request.cursor();
let limit = request.limit.unwrap_or(0);
state
.read_conversation_page
.execute(ReadConversationPageInput {
project_root: project.root,
conversation,
cursor,
limit,
})
.await
.map(TurnPageDto::from)
.map_err(ErrorDto::from)
}
/// `list_live_agents` — list every agent that currently owns a live session
/// (raw PTY **or** structured/chat) and the cell hosting each, so the UI can
/// disable an agent already running in another cell (the "one live session per
/// agent" invariant).
///
/// Reads the **aggregator** [`LiveSessions::live_agents`], the single source of
/// truth for liveness across both registries (PTY + structured). Reading only
/// `terminal_sessions` here was blind to structured chat agents, so a live chat
/// agent would not be disabled in the UI and could be relaunched elsewhere
/// (cadrage v5 §3.3, Trou B). `LiveSessions` is built on the fly from the two
/// `Arc` registries already held by [`AppState`]; the aggregation logic itself
/// is not duplicated.
///
/// `project_id` is accepted for API symmetry and future per-project scoping; the
/// session registry is process-wide today, so the full live set is returned (a
/// project's agent ids are disjoint from other projects' by construction, so the
/// frontend can filter by the agents it knows).
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a malformed project id).
#[tauri::command]
pub fn list_live_agents(
project_id: String,
state: State<'_, AppState>,
) -> Result<LiveAgentListDto, ErrorDto> {
// Validate the id shape for a consistent contract, even though the registry
// is not project-scoped yet.
let _ = parse_project_id(&project_id)?;
let live = LiveSessions::new(
std::sync::Arc::clone(&state.terminal_sessions),
std::sync::Arc::clone(&state.structured_sessions),
);
Ok(LiveAgentListDto::from_snapshots(
live.live_agent_snapshots(),
))
}
/// `attach_live_agent` — rebind an already-running agent session to a visible
/// layout cell without respawning the CLI process.
///
/// This is the backend side of "a cell is a view": a closed cell can leave an
/// agent running in the background, and opening the agent in a new cell updates
/// the session's host node while preserving the PTY/session/scrollback.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for malformed ids, `NOT_FOUND` if the
/// project/agent/live session is unknown).
#[tauri::command]
pub async fn attach_live_agent(
request: AttachLiveAgentRequestDto,
state: State<'_, AppState>,
) -> Result<AttachLiveAgentResponseDto, ErrorDto> {
let project = resolve_project(&request.project_id, &state).await?;
let agent_id = parse_agent_id(&request.agent_id)?;
let node_id = parse_node_id(&request.node_id)?;
state
.attach_live_agent
.execute(AttachLiveAgentInput {
project,
agent_id,
node_id,
})
.map(AttachLiveAgentResponseDto::from)
.map_err(ErrorDto::from)
}
/// `stop_live_agent` — tear down an already-running agent's live session by agent
/// id, polymorphically (PTY kill or structured shutdown), without removing the
/// agent, its tickets, conversation summary or handoff.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for malformed ids, `NOT_FOUND` if the
/// project/agent is unknown or the agent has no live session, `PROCESS` on a
/// kill/shutdown failure).
#[tauri::command]
pub async fn stop_live_agent(
request: StopLiveAgentRequestDto,
state: State<'_, AppState>,
) -> Result<StopLiveAgentResponseDto, ErrorDto> {
let project = resolve_project(&request.project_id, &state).await?;
let agent_id = parse_agent_id(&request.agent_id)?;
let dependencies = state
.orchestrator_service
.active_wait_dependencies(agent_id);
// Backstop no-reply : arrêter l'observateur de fin de tour de l'agent (le handle est
// droppé ⇒ polling stoppé) avant de démonter sa session.
state.stop_turn_watch(agent_id);
for dependency in dependencies {
state.stop_turn_watch(dependency);
}
state
.stop_live_agent
.execute(StopLiveAgentInput { project, agent_id })
.await
.map(StopLiveAgentResponseDto::from)
.map_err(ErrorDto::from)
}
/// `read_agent_context` — read an agent's Markdown context.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if the
/// project or agent is unknown, `STORE` on I/O failure).
#[tauri::command]
pub async fn read_agent_context(
project_id: String,
agent_id: String,
state: State<'_, AppState>,
) -> Result<ReadAgentContextResponseDto, ErrorDto> {
let project = resolve_project(&project_id, &state).await?;
let agent_id = parse_agent_id(&agent_id)?;
state
.read_agent_context
.execute(ReadAgentContextInput { project, agent_id })
.await
.map(ReadAgentContextResponseDto::from)
.map_err(ErrorDto::from)
}
/// `update_agent_context` — overwrite an agent's Markdown context.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if the
/// project or agent is unknown, `STORE` on I/O failure).
#[tauri::command]
pub async fn update_agent_context(
request: UpdateAgentContextRequestDto,
state: State<'_, AppState>,
) -> Result<(), ErrorDto> {
let project = resolve_project(&request.project_id, &state).await?;
let agent_id = parse_agent_id(&request.agent_id)?;
state
.update_agent_context
.execute(UpdateAgentContextInput {
project,
agent_id,
content: request.content,
})
.await
.map_err(ErrorDto::from)
}
/// `delete_agent` — remove an agent from the project manifest.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if the
/// project or agent is unknown, `STORE` on I/O failure).
#[tauri::command]
pub async fn delete_agent(
project_id: String,
agent_id: String,
state: State<'_, AppState>,
) -> Result<(), ErrorDto> {
let project = resolve_project(&project_id, &state).await?;
let agent_id = parse_agent_id(&agent_id)?;
state
.delete_agent
.execute(DeleteAgentInput { project, agent_id })
.await
.map_err(ErrorDto::from)
}
/// `inspect_conversation` — best-effort enriched details (last topic + token
/// indicator) for a resume popup (T7).
///
/// Best-effort by contract: a missing/unsupported inspector or a missing
/// transcript yields **empty details** (absent `lastTopic`/`tokenCount`), never
/// an error. Only a genuine store failure (resolving the agent/profile) errors.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if the
/// project, agent or profile is unknown, `STORE` on a manifest/profile failure).
#[tauri::command]
pub async fn inspect_conversation(
request: InspectConversationRequestDto,
state: State<'_, AppState>,
) -> Result<ConversationDetailsDto, ErrorDto> {
let project = resolve_project(&request.project_id, &state).await?;
let agent_id = parse_agent_id(&request.agent_id)?;
state
.inspect_conversation
.execute(InspectConversationInput {
project,
agent_id,
conversation_id: request.conversation_id,
})
.await
.map(ConversationDetailsDto::from)
.map_err(ErrorDto::from)
}
/// `launch_agent` — spawn an agent's CLI in a PTY and wire its byte stream to
/// the frontend via a [`Channel`].
///
/// Mirrors `open_terminal`: execute the use case (spawn + register session),
/// register the xterm channel in the [`PtyBridge`], then pump PTY output to
/// that channel on a dedicated OS thread.
///
/// Returns the [`TerminalSessionDto`] (its `sessionId` is what
/// `write`/`resize`/`close` reference).
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a bad id/size, `NOT_FOUND` if the
/// project, agent or profile is unknown, `PROCESS` if the PTY fails to spawn).
#[tauri::command]
pub async fn launch_agent(
request: LaunchAgentRequestDto,
on_output: Channel<PtyChunk>,
state: State<'_, AppState>,
) -> Result<TerminalSessionDto, ErrorDto> {
let project = resolve_project(&request.project_id, &state).await?;
let agent_id = parse_agent_id(&request.agent_id)?;
// The hosting cell drives the singleton-invariant guard. Parse it when the
// frontend supplies one; absent ⇒ `None` (a fresh node is minted, and an
// already-live agent is refused).
let node_id = request.node_id.as_deref().map(parse_node_id).transpose()?;
// Compose the MCP runtime (M5d): inject the OS/runtime facts the application
// layer cannot compute. The endpoint comes from the **single source of truth**
// (`mcp_endpoint`), the same value `ensure_mcp_server` binds the listener on
// (cadrage v5 §2). The `--project` is the hyphen-free 32-hex `simple` form the
// M5c handshake guard (`serve_peer`) compares against; the `--requester` is the
// launching agent's id. A missing executable path (should not happen) degrades
// to no runtime ⇒ apply_mcp_config writes the minimal declaration.
// L'exe vient de `idea_exe_path()` (privilégie `$APPIMAGE`, sinon `current_exe`)
// pour que chemin GUI et chemin `ask` écrivent un `command` identique et stable.
let mcp_runtime = crate::mcp_endpoint::idea_exe_path().map(|exe| McpRuntime {
exe,
endpoint: crate::mcp_endpoint::mcp_endpoint(&project.id)
.as_cli_arg()
.to_owned(),
project_id: project.id.as_uuid().simple().to_string(),
requester: agent_id.to_string(),
});
// Functional migration seam: before any launch/reattach/idempotent early-return,
// repair the target Claude run dir so stale `.mcp.json` / `.claude/settings.local.json`
// artefacts from previous AppImages do not survive into this activation.
state.reconcile_claude_run_dirs(&project).await;
// Session-limit resume context (LS7, §21.5): record the Project + cell size keyed by
// agent so an auto-resume (which only carries agent/node/conversation) can recompose a
// full `LaunchAgentInput`. Cloned here — `project` is moved into the launch below.
let resume_project = project.clone();
// Lot LS6 — project root captured before `project` moves, for the off-hot-path log
// rotation triggered at thread (re)open below.
let rotation_root = project.root.clone();
// Backstop no-reply — project root captured before `project` moves, to arm the
// end-of-turn transcript watcher for the launched agent (cf. `AppState::arm_turn_watch`).
let watch_root = project.root.clone();
let output = state
.launch_agent
.execute(LaunchAgentInput {
project,
agent_id,
rows: request.rows,
cols: request.cols,
node_id,
// Resume id is a property of the hosting cell; the frontend passes the
// leaf's current conversation id here.
conversation_id: request.conversation_id.clone(),
mcp_runtime,
allow_structured_alongside_pty: false,
})
.await
.map_err(ErrorDto::from)?;
if let Ok(mut contexts) = state.resume_contexts.lock() {
contexts.insert(
agent_id,
crate::state::ResumeContext {
project: resume_project,
rows: request.rows,
cols: request.cols,
},
);
}
// Backstop no-reply : armer l'observateur de fin de tour transcript pour la cible si
// son profil est supporté (Claude). Idempotent par remplacement à chaque (re)lancement
// effectif (profil résolu présent) ; no-op sur reattach (profil `None`) — le handle
// posé au 1er lancement persiste. cwd = run dir isolé de l'agent.
if let Some(profile) = output.profile.as_ref() {
state.arm_turn_watch(
&watch_root,
agent_id,
profile,
output.assigned_conversation_id.clone(),
);
}
// Lot LS6 — rotation **best-effort** du log, déclenchée à la (re)ouverture du fil et
// **hors chemin chaud** : détachée (`tokio::spawn`) pour n'ajouter aucune latence au
// launch, erreurs **avalées** (la rotation ne doit jamais casser une reprise). Un
// `append` ne déclenche JAMAIS la rotation. Skippée si la cellule ne porte pas une
// `conversation_id` UUID (rien à roter).
if let Some(conversation) = request
.conversation_id
.as_deref()
.and_then(|raw| uuid::Uuid::parse_str(raw).ok())
.map(domain::ConversationId::from_uuid)
{
let rotate = std::sync::Arc::clone(&state.rotate_conversation_log);
tokio::spawn(async move {
let _ = rotate
.execute(RotateConversationLogInput {
project_root: rotation_root,
conversation,
})
.await;
});
}
let session_id = output.session.id;
// Host cell of the freshly (re)launched session — the pivot the level-2 tap reports
// to the service alongside the agent.
let host_node_id = output.session.node_id;
// §17.4/§17.6: a structured launch routes to an `AgentSession`, registered
// **only** in `StructuredSessions` — its id is never a live PTY. Such a cell is
// a chat view driven by `agent_send`/`reattach_agent_chat`, not by xterm bytes,
// so we must NOT wire it to the PTY bridge. Doing so would call
// `subscribe_output` with an id the PTY adapter has never seen → `NotFound`
// ("process error: pty handle not found"), failing every structured agent
// launch. Only the raw-PTY path (`structured: None`) gets the byte pump.
if output.structured.is_none() {
// Register the xterm output channel for this session.
let gen = state.pty_bridge.register(session_id, on_output);
// Level-2 session-limit detector (§21, niveau 2 — PTY/TUI sans adapter
// structuré). Selection rule (§21.10-4) is the single infra source `applies`:
// arm a parser **only** for a profile without a structured adapter that declares
// a `rate_limit_pattern` (anti-double-détection avec le niveau 1). A `None`
// profile (reattach/idempotent) or an invalid regex ⇒ no parser, jamais de panique.
let rate_limit_parser = output
.profile
.as_ref()
.filter(|p| infrastructure::ratelimit::applies(p))
.and_then(|p| p.rate_limit_pattern.as_ref())
.and_then(infrastructure::RateLimitParser::new);
// Subscribe to the PTY's byte stream and pump it to the channel.
// The stream is a blocking iterator; it runs on a dedicated OS thread and
// ends when the PTY hits EOF or this attach is superseded.
let handle = PtyHandle { session_id };
match state.pty_port.subscribe_output(&handle) {
Ok(stream) => {
let bridge: std::sync::Arc<PtyBridge> = std::sync::Arc::clone(&state.pty_bridge);
// Captured for the level-2 tap (moved into the pump thread).
let service = std::sync::Arc::clone(&state.session_limit_service);
let detect_clock = std::sync::Arc::new(infrastructure::SystemClock::new());
let conversation_id = request.conversation_id.clone();
std::thread::spawn(move || {
for chunk in stream {
// §21.10-4 tap (best-effort par fragment) : avant de consommer le
// fragment, on cherche le motif de limite. Une détection arme la
// reprise via le service (détecter→planifier). Dormant si pas de
// parser. La fragmentation PTV (motif coupé entre deux fragments)
// est un raté best-effort connu pour LS7.
if let Some(parser) = &rate_limit_parser {
let text = String::from_utf8_lossy(&chunk);
if let Some(limit) = parser
.detect(&text, domain::ports::Clock::now_millis(&*detect_clock))
{
service.on_rate_limited(
agent_id,
host_node_id,
conversation_id.clone(),
limit.resets_at_ms,
);
}
}
if !bridge.send_output(&session_id, chunk) {
break;
}
}
bridge.unregister_if(&session_id, gen);
});
}
Err(e) => {
state.pty_bridge.unregister(&session_id);
return Err(ErrorDto::from(AppError::from(e)));
}
}
}
Ok(TerminalSessionDto::from(output))
}
/// `change_agent_profile` — hot-swap an agent's runtime profile (§15.1).
///
/// Mutates the profile in the manifest, clears the now-foreign conversation id on
/// every persisted layout cell hosting the agent, and — if the agent is live —
/// kills its PTY and relaunches the session in the same cell with the new engine.
/// Announces [`DomainEvent::AgentProfileChanged`].
///
/// Returns the mutated [`AgentDto`] and the relaunched [`TerminalSessionDto`] when
/// a live session was hot-swapped (absent otherwise).
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for malformed ids, `NOT_FOUND` if the
/// project, agent or target profile is unknown, `STORE`/`FILESYSTEM`/`PROCESS` on
/// the respective port failures).
#[tauri::command]
pub async fn change_agent_profile(
request: ChangeAgentProfileRequestDto,
state: State<'_, AppState>,
) -> Result<ChangeAgentProfileDto, ErrorDto> {
let project = resolve_project(&request.project_id, &state).await?;
let agent_id = parse_agent_id(&request.agent_id)?;
let profile_id = parse_profile_id(&request.profile_id)?;
state
.change_agent_profile
.execute(ChangeAgentProfileInput {
project,
agent_id,
profile_id,
rows: request.rows,
cols: request.cols,
})
.await
.map(ChangeAgentProfileDto::from)
.map_err(ErrorDto::from)
}
/// `agent_send` — send a prompt to a live **structured** (chat) session and pump
/// the turn's reply events to the frontend over `on_reply` (ARCHITECTURE §17.7).
///
/// Twin of the PTY output pump: resolve the live [`AgentSession`] from the
/// structured registry, open the turn stream (`session.send`), then drain that
/// blocking [`ReplyStream`] on a dedicated OS thread, mapping each
/// [`ReplyEvent`](domain::ports::ReplyEvent) to a [`ReplyChunk`] and forwarding it
/// through the [`ChatBridge`]. The turn ends deterministically at
/// [`ReplyChunk::Final`]; the stream is bounded and closes right after.
///
/// The channel is (re-)registered each call, bumping the bridge **generation** so
/// a previous attach's pump can no longer deliver (generation supersede): only the
/// current generation's channel receives chunks, never a double emission.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if no live
/// structured session owns the id, `PROCESS` if the turn fails to start).
#[tauri::command]
pub async fn agent_send(
session_id: String,
prompt: String,
on_reply: Channel<ReplyChunk>,
state: State<'_, AppState>,
) -> Result<(), ErrorDto> {
let sid = parse_session_id(&session_id)?;
// Resolve the live structured session (the registry is the single source of
// truth for liveness — no adapter is constructed here, §17.8 rule D).
let session = state
.structured_sessions
.session(&sid)
.ok_or_else(|| ErrorDto::from(AppError::NotFound(format!("structured session {sid}"))))?;
// (Re-)register the reply channel; bumps the generation so an earlier attach's
// pump (if any) is superseded and stops delivering to its stale channel.
let gen = state.chat_bridge.register(sid, on_reply);
// Open the turn stream. A start failure leaves the just-registered channel in
// place (the cell stays attached, ready for a retry) — mirrors the PTY pump,
// which only unregisters on a hard subscribe failure; here the session is
// still live, so we keep the attach and surface the error.
let stream = session
.send(&prompt)
.await
.map_err(|e| ErrorDto::from(AppError::from(e)))?;
// Drain the blocking reply iterator on a dedicated OS thread (the stream is a
// synchronous `Iterator`, exactly like the PTY byte stream). It runs to the
// `Final` event (or stream end / superseded channel), then detaches *only its
// own* generation so a concurrent re-attach is never torn down.
let bridge = std::sync::Arc::clone(&state.chat_bridge);
// Level-1 session-limit tap (§21, niveau 1 — structuré). Resolve the agent, host
// cell and engine conversation once from the live registry; a `RateLimited` turn
// event then feeds the service (détecter→planifier). Without a conversation id the
// service must not pretend an automatic resume is armed.
let service = std::sync::Arc::clone(&state.session_limit_service);
let meta = state.structured_sessions.meta_for_session(&sid);
std::thread::spawn(move || {
let mut terminal_visible = false;
for event in stream {
// Non-terminal, sans contenu chat : un signal de limite alimente le service
// (le badge UI vient du bus `AgentRateLimited`, pas du flux chat) puis on
// continue à drainer comme pour un battement.
if let domain::ports::ReplyEvent::RateLimited { resets_at_ms } = &event {
if let Some((agent_id, node_id, conversation_id)) = &meta {
let (conversation_id, resets_at_ms) = match (conversation_id, resets_at_ms) {
(Some(conversation_id), resets_at_ms) => {
(Some(conversation_id.clone()), *resets_at_ms)
}
(None, None) => (None, None),
// Reset connu mais conversation moteur absente : ne pas armer
// une reprise non reprenable ; surfacer le fallback humain.
(None, Some(_)) => (None, None),
};
service.on_rate_limited(*agent_id, *node_id, conversation_id, resets_at_ms);
}
}
// Heartbeats carry no chat content (readiness/heartbeat lot 1) ⇒ no wire
// chunk; skip them while still draining so the turn runs to its `Final`.
let Some(chunk) = crate::chat::chunk_from_event(event) else {
continue;
};
if matches!(chunk, ReplyChunk::Final { .. } | ReplyChunk::Error { .. }) {
terminal_visible = true;
}
// `send_output` always records into the conversation scrollback; the
// boolean only reflects live delivery. If the view navigated away
// (no channel at this generation) we keep draining so the turn still
// completes and the scrollback stays whole for the next re-attach.
let _ = bridge.send_output(&sid, chunk);
}
if !terminal_visible {
let _ = bridge.send_output(
&sid,
ReplyChunk::Error {
message: "Le modèle n'a renvoyé aucune réponse.".to_owned(),
},
);
}
bridge.detach_if(&sid, gen);
});
Ok(())
}
/// `cancel_resume` — annule la **reprise automatique** armée pour un agent limité
/// (ARCHITECTURE §21.1-4, fenêtre annulable).
///
/// Délègue à [`SessionLimitService::cancel_resume`](application::SessionLimitService::cancel_resume) :
/// désarme le réveil et, **seulement si** l'annulation a réussi (le réveil n'avait pas
/// encore tiré), publie `AgentResumeCancelled`. Renvoie `true` ssi une reprise a
/// effectivement été annulée (`false` si aucune n'était armée, ou si elle venait de
/// tirer — auquel cas la reprise suit son cours).
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a malformed agent id).
#[tauri::command]
pub async fn cancel_resume(agent_id: String, state: State<'_, AppState>) -> Result<bool, ErrorDto> {
let id = parse_agent_id(&agent_id)?;
Ok(state.session_limit_service.cancel_resume(id))
}
/// `set_resume_at` — **filet humain niveau 3** (ARCHITECTURE §21.1) : l'utilisateur a
/// saisi l'heure de reset d'un agent en limite **suspectée** (rien n'a matché
/// automatiquement). Arme la **même** reprise annulable que les niveaux 1/2.
///
/// Le front ne dispose que de l'`agent_id` ; on résout côté backend :
/// - `node_id` : la cellule vivante hébergeant l'agent, cherchée dans la registry
/// structurée puis dans la registry terminal ([`StructuredSessions::node_for_agent`]
/// / [`TerminalSessions::node_for_agent`]). Sans cellule vivante, la saisie n'a pas de
/// cible ⇒ `NOT_FOUND`.
/// - `conversation_id` : best-effort via la session structurée de l'agent
/// ([`AgentSession::conversation_id`]) ; `None` toléré (reprise en mode dégradé).
///
/// Délègue ensuite à
/// [`SessionLimitService::confirm_human_resume`](application::SessionLimitService::confirm_human_resume),
/// qui réémet la paire `AgentRateLimited{Some}` + `AgentResumeScheduled` déjà relayée au
/// front. Aucun nouvel événement.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a malformed agent id, `NOT_FOUND` if no live
/// cell hosts the agent).
#[tauri::command]
pub async fn set_resume_at(
agent_id: String,
resets_at_ms: i64,
state: State<'_, AppState>,
) -> Result<(), ErrorDto> {
let id = parse_agent_id(&agent_id)?;
// Résolution agent→cellule : la registry des sessions vivantes est la source de
// vérité. On regarde d'abord le structuré (qui porte aussi le `conversation_id`),
// puis le terminal (PTY).
let node_id = state
.structured_sessions
.node_for_agent(&id)
.or_else(|| state.terminal_sessions.node_for_agent(&id))
.ok_or_else(|| {
ErrorDto::from(AppError::NotFound(format!(
"aucune cellule vivante pour l'agent {id}"
)))
})?;
// `conversation_id` best-effort : seule une session structurée vivante l'expose.
let conversation_id = state
.structured_sessions
.session_for_agent(&id)
.and_then(|s| s.conversation_id());
state
.session_limit_service
.confirm_human_resume(id, node_id, conversation_id, resets_at_ms);
Ok(())
}
/// `interrupt_agent` — the **Interrompre** path (cadrage C4 §4.2).
///
/// Routes to [`OrchestratorService::interrupt_agent`], which `preempt`s the agent's
/// running turn (best-effort interrupt to its PTY). Not an enqueue; resolves no
/// ticket. Idempotent on an idle agent.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a malformed id or unwired mediator,
/// `NOT_FOUND` if the project or agent is unknown).
#[tauri::command]
pub async fn interrupt_agent(
request: InterruptAgentRequestDto,
state: State<'_, AppState>,
) -> Result<(), ErrorDto> {
let project = resolve_project(&request.project_id, &state).await?;
let agent_id = parse_agent_id(&request.agent_id)?;
state
.orchestrator_service
.interrupt_agent(&project, agent_id)
.await
.map(|_| ())
.map_err(ErrorDto::from)
}
/// `delegation_delivered` — the frontend write-portal's **ack** (ARCHITECTURE §20.3).
///
/// Called once the cell has physically written a delegation `ticket` into the agent's
/// native PTY (text + submit sequence). Routes to the best-effort
/// [`OrchestratorService::note_delegation_delivered`] (observability/log only): it does
/// **not** change correlation — the requester's `ask` is still woken by `idea_reply`,
/// and timeouts/cycle guards are untouched. Infallible on the application side; only a
/// malformed id (project/agent/ticket) yields an `INVALID` error here.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if the project is
/// unknown).
#[tauri::command]
pub async fn delegation_delivered(
request: DeliveredDelegationRequestDto,
state: State<'_, AppState>,
) -> Result<(), ErrorDto> {
let project = resolve_project(&request.project_id, &state).await?;
let agent_id = parse_agent_id(&request.agent_id)?;
let ticket = parse_ticket_id(&request.ticket)?;
application::diag!(
"[delivery] delegation_delivered command: project={} agent={agent_id} ticket={ticket}",
project.id
);
state
.orchestrator_service
.note_delegation_delivered(&project, agent_id, ticket);
Ok(())
}
/// `set_front_attached` — the write-portal reports whether a **frontend terminal cell**
/// is mounted for an agent (mount ⇒ `true`, unmount ⇒ `false`).
///
/// Routes to [`OrchestratorService::set_agent_front_attached`]. This is what lets the
/// mediator deliver a turn to a **headless** (background-delegated, cell-less) agent by
/// writing its PTY itself: with no mounted cell nobody consumes `DelegationReady`, so
/// the task would otherwise be lost (a delegated agent that never receives — and never
/// answers — its task). An agent **with** a cell keeps the frontend write-portal path.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID`) for a malformed agent id.
#[tauri::command]
pub async fn set_front_attached(
request: FrontAttachedRequestDto,
state: State<'_, AppState>,
) -> Result<(), ErrorDto> {
let agent_id = parse_agent_id(&request.agent_id)?;
application::diag!(
"[delivery] set_front_attached command: agent={agent_id} attached={}",
request.attached
);
state
.orchestrator_service
.set_agent_front_attached(agent_id, request.attached);
Ok(())
}
/// `reattach_agent_chat` — re-bind a view to a **still-living** structured session
/// without re-sending or re-spawning it (ARCHITECTURE §17.6/§17.7).
///
/// Navigation (switching layout/tab) tears the chat view down but must NOT kill
/// the backend session (the AI keeps running in the registry). When the view comes
/// back it calls this, which:
/// 1. reads the session's retained **conversation scrollback** (the chunks already
/// streamed) so the chat can repaint its prior turns,
/// 2. registers the new per-session [`Channel`] in the [`ChatBridge`], bumping the
/// generation so the previous attach's pump can no longer deliver.
///
/// No new turn is started here (a turn is driven by `agent_send`); a pump only
/// runs while a turn is in flight. Returns the scrollback; the frontend replays it
/// then receives subsequent chunks over `on_reply`.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if no live
/// structured session owns the id — the caller then falls back to a fresh launch).
#[tauri::command]
pub fn reattach_agent_chat(
session_id: String,
on_reply: Channel<ReplyChunk>,
state: State<'_, AppState>,
) -> Result<ReattachChatDto, ErrorDto> {
let sid = parse_session_id(&session_id)?;
// A missing live session means the conversation is gone (closed/never live) —
// surfaced as NOT_FOUND so the caller falls back to opening a fresh cell.
if state.structured_sessions.session(&sid).is_none() {
return Err(ErrorDto::from(AppError::NotFound(format!(
"structured session {sid}"
))));
}
// (1) Snapshot the conversation scrollback before swapping the channel.
let scrollback = state.chat_bridge.scrollback(&sid);
// (2) Register the new channel, superseding any previous attach (the bumped
// generation makes the prior pump's `detach_if` a no-op, so it can't tear down
// this live channel — generation supersede, no double emission).
let _gen = state.chat_bridge.register(sid, on_reply);
Ok(ReattachChatDto {
session_id,
scrollback,
})
}
/// `close_agent_session` — shut a live structured session down and tear its
/// transport (channel + conversation scrollback) down (ARCHITECTURE §17.7).
///
/// Removes the session from the structured registry (so liveness checks no longer
/// see it), `shutdown`s it polymorphically (kills the underlying process/SDK;
/// idempotent by the port contract), and unregisters it from the [`ChatBridge`].
/// The chat twin of `close_terminal`.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if no live
/// structured session owns the id, `PROCESS` if the shutdown fails).
#[tauri::command]
pub async fn close_agent_session(
session_id: String,
state: State<'_, AppState>,
) -> Result<(), ErrorDto> {
let sid = parse_session_id(&session_id)?;
// Remove from the registry first so concurrent liveness checks stop seeing it;
// we then own the only handle to shut down (outside the registry lock).
let session = state
.structured_sessions
.remove(&sid)
.ok_or_else(|| ErrorDto::from(AppError::NotFound(format!("structured session {sid}"))))?;
let result = session
.shutdown()
.await
.map_err(|e| ErrorDto::from(AppError::from(e)));
// Tear down the transport regardless of the shutdown outcome (the session is
// already out of the registry; the cell is gone).
state.chat_bridge.unregister(&sid);
result
}
/// `list_resumable_agents` — read-only inventory of an open project's resumable
/// agent cells (§15.2). Each entry carries the agent + its host cell, the CLI
/// conversation id to resume (absent ⇒ fresh relaunch), the `was_running` flag
/// frozen at close, and whether the profile can resume a conversation.
///
/// Best-effort by contract: an unreadable project/layout/manifest degrades to an
/// empty list, never an error. Drives the reopen panel, which reuses
/// `launch_agent` for the actual resume (no new resume command).
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if the
/// project is unknown, `STORE` on a project-registry failure).
#[tauri::command]
pub async fn list_resumable_agents(
project_id: String,
state: State<'_, AppState>,
) -> Result<ResumableAgentListDto, ErrorDto> {
let project = resolve_project(&project_id, &state).await?;
state
.list_resumable_agents
.execute(ListResumableAgentsInput { project })
.await
.map(ResumableAgentListDto::from)
.map_err(ErrorDto::from)
}
// ---------------------------------------------------------------------------
// Templates & sync (L7)
// ---------------------------------------------------------------------------
/// `create_template` — create a template in the global IDE store.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for an empty name or malformed profile id,
/// `STORE` on persistence failure).
#[tauri::command]
pub async fn create_template(
request: CreateTemplateRequestDto,
state: State<'_, AppState>,
) -> Result<TemplateDto, ErrorDto> {
let input = request.into_input()?;
state
.create_template
.execute(input)
.await
.map(TemplateDto::from)
.map_err(ErrorDto::from)
}
/// `update_template` — update a template's content (bumps version, fires
/// `TemplateUpdated` event).
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if the
/// template is unknown, `STORE` on persistence failure).
#[tauri::command]
pub async fn update_template(
request: UpdateTemplateRequestDto,
state: State<'_, AppState>,
) -> Result<TemplateDto, ErrorDto> {
let input = request.into_input()?;
state
.update_template
.execute(input)
.await
.map(TemplateDto::from)
.map_err(ErrorDto::from)
}
/// `list_templates` — list all templates in the global IDE store.
///
/// # Errors
/// Returns an [`ErrorDto`] (`STORE` on persistence failure).
#[tauri::command]
pub async fn list_templates(state: State<'_, AppState>) -> Result<TemplateListDto, ErrorDto> {
state
.list_templates
.execute()
.await
.map(TemplateListDto::from)
.map_err(ErrorDto::from)
}
/// `delete_template` — remove a template from the global IDE store.
///
/// Agents previously created from it keep their `.md`; drift detection simply
/// finds nothing to compare against afterwards.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if the
/// template is unknown, `STORE` on failure).
#[tauri::command]
pub async fn delete_template(
template_id: String,
state: State<'_, AppState>,
) -> Result<(), ErrorDto> {
let id = parse_template_id(&template_id)?;
state
.delete_template
.execute(DeleteTemplateInput { template_id: id })
.await
.map_err(ErrorDto::from)
}
/// `create_agent_from_template` — instantiate a project agent from a template.
///
/// Copies the template's Markdown content, links the agent origin and version,
/// and records the manifest entry.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if the
/// project or template is unknown, `STORE` on I/O failure).
#[tauri::command]
pub async fn create_agent_from_template(
request: CreateAgentFromTemplateRequestDto,
state: State<'_, AppState>,
) -> Result<AgentDto, ErrorDto> {
let project = resolve_project(&request.project_id, &state).await?;
let input = request.into_input(project)?;
state
.create_agent_from_template
.execute(input)
.await
.map(|out| AgentDto(out.agent))
.map_err(ErrorDto::from)
}
/// `detect_agent_drift` — list which synchronized agents are behind their
/// template (version drift).
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if the
/// project is unknown, `STORE` on I/O failure).
#[tauri::command]
pub async fn detect_agent_drift(
project_id: String,
state: State<'_, AppState>,
) -> Result<AgentDriftListDto, ErrorDto> {
let project = resolve_project(&project_id, &state).await?;
state
.detect_agent_drift
.execute(DetectAgentDriftInput { project })
.await
.map(AgentDriftListDto::from)
.map_err(ErrorDto::from)
}
/// `sync_agent_with_template` — apply the latest template content to a
/// synchronized agent.
///
/// Returns whether a sync was applied and the resulting version.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if the
/// project, agent or template is unknown, `STORE` on I/O failure).
#[tauri::command]
pub async fn sync_agent_with_template(
request: SyncAgentWithTemplateRequestDto,
state: State<'_, AppState>,
) -> Result<SyncResultDto, ErrorDto> {
let project = resolve_project(&request.project_id, &state).await?;
let agent_id = parse_agent_id(&request.agent_id)?;
state
.sync_agent_with_template
.execute(SyncAgentWithTemplateInput { project, agent_id })
.await
.map(SyncResultDto::from)
.map_err(ErrorDto::from)
}
// ---------------------------------------------------------------------------
// Git (L8)
// ---------------------------------------------------------------------------
/// `git_status` — report the working-tree status of a project's repository.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a bad project id or root, `GIT` if
/// the repo is missing or the operation fails).
#[tauri::command]
pub async fn git_status(
project_id: String,
state: State<'_, AppState>,
) -> Result<GitStatusListDto, ErrorDto> {
let project = resolve_project(&project_id, &state).await?;
let root = project.root.as_str().to_owned();
state
.git_status
.execute(GitStatusInput { root })
.await
.map(GitStatusListDto::from)
.map_err(ErrorDto::from)
}
/// `git_stage` — stage a path in a project's repository.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a bad project id or root, `GIT` on
/// failure).
#[tauri::command]
pub async fn git_stage(
request: GitStageRequestDto,
state: State<'_, AppState>,
) -> Result<(), ErrorDto> {
let project = resolve_project(&request.project_id, &state).await?;
let root = project.root.as_str().to_owned();
state
.git_stage
.execute(GitStagePathInput {
root,
path: request.path,
})
.await
.map_err(ErrorDto::from)
}
/// `git_unstage` — unstage a path in a project's repository.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a bad project id or root, `GIT` on
/// failure).
#[tauri::command]
pub async fn git_unstage(
request: GitStageRequestDto,
state: State<'_, AppState>,
) -> Result<(), ErrorDto> {
let project = resolve_project(&request.project_id, &state).await?;
let root = project.root.as_str().to_owned();
state
.git_unstage
.execute(GitStagePathInput {
root,
path: request.path,
})
.await
.map_err(ErrorDto::from)
}
/// `git_commit` — create a commit in a project's repository.
///
/// Announces [`DomainEvent::GitStateChanged`].
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a bad id or an empty message, `GIT`
/// on failure).
#[tauri::command]
pub async fn git_commit(
request: GitCommitRequestDto,
state: State<'_, AppState>,
) -> Result<GitCommitDto, ErrorDto> {
let project = resolve_project(&request.project_id, &state).await?;
let root = project.root.as_str().to_owned();
state
.git_commit
.execute(GitCommitInput {
project_id: project.id,
root,
message: request.message,
})
.await
.map(GitCommitDto::from)
.map_err(ErrorDto::from)
}
/// `git_branches` — list branches and the current one for a project's repository.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a bad project id or root, `GIT` on
/// failure).
#[tauri::command]
pub async fn git_branches(
project_id: String,
state: State<'_, AppState>,
) -> Result<GitBranchesDto, ErrorDto> {
let project = resolve_project(&project_id, &state).await?;
let root = project.root.as_str().to_owned();
state
.git_branches
.execute(GitBranchesInput { root })
.await
.map(GitBranchesDto::from)
.map_err(ErrorDto::from)
}
/// `git_checkout` — check out a branch in a project's repository.
///
/// Announces [`DomainEvent::GitStateChanged`].
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a bad project id or root, `GIT` on
/// failure).
#[tauri::command]
pub async fn git_checkout(
request: GitCheckoutRequestDto,
state: State<'_, AppState>,
) -> Result<(), ErrorDto> {
let project = resolve_project(&request.project_id, &state).await?;
let root = project.root.as_str().to_owned();
state
.git_checkout
.execute(GitCheckoutInput {
project_id: project.id,
root,
branch: request.branch,
})
.await
.map_err(ErrorDto::from)
}
/// `git_log` — return the recent commit log for a project's repository.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a bad project id or root, `GIT` on
/// failure).
#[tauri::command]
pub async fn git_log(
project_id: String,
limit: usize,
state: State<'_, AppState>,
) -> Result<GitCommitListDto, ErrorDto> {
let project = resolve_project(&project_id, &state).await?;
let root = project.root.as_str().to_owned();
state
.git_log
.execute(GitLogInput { root, limit })
.await
.map(GitCommitListDto::from)
.map_err(ErrorDto::from)
}
/// `git_init` — initialise a git repository at a project's root.
///
/// Announces [`DomainEvent::GitStateChanged`].
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a bad project id or root, `GIT` on
/// failure).
#[tauri::command]
pub async fn git_init(project_id: String, state: State<'_, AppState>) -> Result<(), ErrorDto> {
let project = resolve_project(&project_id, &state).await?;
let root = project.root.as_str().to_owned();
state
.git_init
.execute(GitInitInput {
project_id: project.id,
root,
})
.await
.map_err(ErrorDto::from)
}
/// `git_graph` — return the commit graph for all local branches of a project.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a bad project id or root, `GIT` on
/// failure).
#[tauri::command]
pub async fn git_graph(
project_id: String,
limit: usize,
state: State<'_, AppState>,
) -> Result<GraphCommitListDto, ErrorDto> {
let project = resolve_project(&project_id, &state).await?;
let root = project.root.as_str().to_owned();
state
.git_graph
.execute(GitGraphInput { root, limit })
.await
.map(GraphCommitListDto::from)
.map_err(ErrorDto::from)
}
// ---------------------------------------------------------------------------
// Windows (L10)
// ---------------------------------------------------------------------------
use crate::dto::{parse_tab_id, MoveTabResultDto};
use application::MoveTabToNewWindowInput;
/// Event emitted for detached view-window lifecycle changes.
pub const VIEW_WINDOW_LIFECYCLE_EVENT: &str = "view-window://lifecycle";
/// Event emitted when the main-window focused project changes.
pub const FOCUSED_PROJECT_CHANGED_EVENT: &str = "focused-project://changed";
/// Response returned by `open_view_window`.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct OpenViewWindowResponseDto {
/// Stable Tauri window label for this panel.
pub label: String,
/// App URL loaded by the panel-only window.
pub url: String,
/// Whether the command reused and focused an existing window.
pub already_open: bool,
}
/// Response returned by `close_view_window`.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CloseViewWindowResponseDto {
/// Stable Tauri window label for this panel.
pub label: String,
/// `true` when a live window was found and close was requested.
pub closed: bool,
}
/// Snapshot returned by `list_open_view_windows`.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ViewWindowSnapshot {
/// Panel id rendered by the detached window.
pub panel: String,
/// Stable Tauri window label.
pub label: String,
/// Whether Tauri currently reports the OS window as visible.
pub visible: bool,
}
/// Payload emitted on `view-window://lifecycle`.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ViewWindowLifecycleEventDto {
/// Lifecycle kind: `"opened"`, `"focused"` or `"closed"`.
pub kind: &'static str,
/// Panel id rendered by the detached window.
pub panel: String,
/// Stable Tauri window label.
pub label: String,
}
/// Payload emitted on `focused-project://changed`.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct FocusedProjectChangedEventDto {
/// Focused project, or `null` when no project is focused.
pub project: Option<FocusedProjectDto>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ViewPanel {
Projects,
Context,
Work,
Tickets,
Agents,
Templates,
Skills,
Permissions,
Memory,
Git,
}
impl ViewPanel {
pub(crate) fn parse(raw: &str) -> Result<Self, ErrorDto> {
match raw {
"projects" => Ok(Self::Projects),
"context" => Ok(Self::Context),
"work" => Ok(Self::Work),
"tickets" => Ok(Self::Tickets),
"agents" => Ok(Self::Agents),
"templates" => Ok(Self::Templates),
"skills" => Ok(Self::Skills),
"permissions" => Ok(Self::Permissions),
"memory" => Ok(Self::Memory),
"git" => Ok(Self::Git),
_ => Err(ErrorDto {
code: "INVALID".to_owned(),
message: format!("invalid view panel: {raw}"),
}),
}
}
pub(crate) const fn as_str(self) -> &'static str {
match self {
Self::Projects => "projects",
Self::Context => "context",
Self::Work => "work",
Self::Tickets => "tickets",
Self::Agents => "agents",
Self::Templates => "templates",
Self::Skills => "skills",
Self::Permissions => "permissions",
Self::Memory => "memory",
Self::Git => "git",
}
}
pub(crate) const fn title(self) -> &'static str {
match self {
Self::Projects => "Projects",
Self::Context => "Project context",
Self::Work => "Work state",
Self::Tickets => "Tickets",
Self::Agents => "Agents",
Self::Templates => "Templates",
Self::Skills => "Skills",
Self::Permissions => "Permissions",
Self::Memory => "Memory",
Self::Git => "Git",
}
}
}
pub(crate) fn view_window_label(panel: ViewPanel) -> String {
format!("view-{}", panel.as_str())
}
pub(crate) fn view_window_url(panel: ViewPanel) -> String {
format!("index.html?panel={}", panel.as_str())
}
fn view_panel_from_window_label(label: &str) -> Option<ViewPanel> {
let rest = label.strip_prefix("view-")?;
if let Ok(panel) = ViewPanel::parse(rest) {
return Some(panel);
}
let (panel, project_id) = rest.rsplit_once('-')?;
if project_id.len() != 32 || uuid::Uuid::parse_str(project_id).is_err() {
return None;
}
ViewPanel::parse(panel).ok()
}
pub(crate) fn emit_view_window_lifecycle(
app: &AppHandle,
kind: &'static str,
panel: ViewPanel,
label: &str,
) {
let _ = app.emit(
VIEW_WINDOW_LIFECYCLE_EVENT,
ViewWindowLifecycleEventDto {
kind,
panel: panel.as_str().to_owned(),
label: label.to_owned(),
},
);
}
/// `set_focused_project` — update the backend focused-project state and notify panels.
#[tauri::command]
pub async fn set_focused_project(
app: AppHandle,
project: Option<FocusedProjectDto>,
state: State<'_, AppState>,
) -> Result<(), ErrorDto> {
state.set_focused_project(project.clone());
app.emit(
FOCUSED_PROJECT_CHANGED_EVENT,
FocusedProjectChangedEventDto { project },
)
.map_err(internal_window_error)
}
/// `get_focused_project` — read the backend focused-project state.
#[tauri::command]
pub async fn get_focused_project(
state: State<'_, AppState>,
) -> Result<Option<FocusedProjectDto>, ErrorDto> {
Ok(state.get_focused_project())
}
/// `list_open_view_windows` — return the detached panel windows currently
/// registered by Tauri.
#[tauri::command]
pub fn list_open_view_windows(app: AppHandle) -> Vec<ViewWindowSnapshot> {
let mut windows = app
.webview_windows()
.into_iter()
.filter_map(|(label, window)| {
let panel = view_panel_from_window_label(&label)?;
Some(ViewWindowSnapshot {
panel: panel.as_str().to_owned(),
label,
visible: window.is_visible().unwrap_or(true),
})
})
.collect::<Vec<_>>();
windows.sort_by(|left, right| {
left.panel
.cmp(&right.panel)
.then(left.label.cmp(&right.label))
});
windows
}
/// `open_view_window` — open or focus a detached OS window for one panel.
///
/// The window is a normal decorated, resizable system window. Its webview loads
/// `index.html?panel=<panel>` so the frontend can boot a panel-only shell that
/// follows the backend focused-project state.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for an unknown panel, `INTERNAL` if Tauri
/// fails to create/focus the window).
#[tauri::command]
pub async fn open_view_window(
app: AppHandle,
panel: String,
) -> Result<OpenViewWindowResponseDto, ErrorDto> {
let panel = ViewPanel::parse(&panel)?;
let label = view_window_label(panel);
let url = view_window_url(panel);
if let Some(window) = app.get_webview_window(&label) {
window.show().map_err(internal_window_error)?;
window.set_focus().map_err(internal_window_error)?;
emit_view_window_lifecycle(&app, "focused", panel, &label);
return Ok(OpenViewWindowResponseDto {
label,
url,
already_open: true,
});
}
let window = WebviewWindowBuilder::new(&app, &label, WebviewUrl::App(url.clone().into()))
.title(format!("IdeA - {}", panel.title()))
.inner_size(1120.0, 760.0)
.min_inner_size(720.0, 480.0)
.resizable(true)
.maximizable(true)
.minimizable(true)
.closable(true)
.decorations(true)
.build()
.map_err(internal_window_error)?;
let event_app = app.clone();
let event_label = label.clone();
window.on_window_event(move |event| {
if let WindowEvent::CloseRequested { .. } = event {
emit_view_window_lifecycle(&event_app, "closed", panel, &event_label);
}
});
emit_view_window_lifecycle(&app, "opened", panel, &label);
Ok(OpenViewWindowResponseDto {
label,
url,
already_open: false,
})
}
/// `close_view_window` — request closing the detached OS window for one panel.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for an unknown panel, `INTERNAL` if Tauri
/// fails to close the window).
#[tauri::command]
pub async fn close_view_window(
app: AppHandle,
panel: String,
) -> Result<CloseViewWindowResponseDto, ErrorDto> {
let panel = ViewPanel::parse(&panel)?;
let label = view_window_label(panel);
let Some(window) = app.get_webview_window(&label) else {
return Ok(CloseViewWindowResponseDto {
label,
closed: false,
});
};
window.close().map_err(internal_window_error)?;
Ok(CloseViewWindowResponseDto {
label,
closed: true,
})
}
fn internal_window_error(error: impl std::fmt::Display) -> ErrorDto {
ErrorDto {
code: "INTERNAL".to_owned(),
message: error.to_string(),
}
}
#[cfg(test)]
mod view_window_tests {
use super::*;
#[test]
fn view_window_label_and_url_are_stable() {
let panel = ViewPanel::Tickets;
assert_eq!(view_window_label(panel), "view-tickets");
assert_eq!(view_window_url(panel), "index.html?panel=tickets");
}
#[test]
fn view_window_rejects_unknown_panel() {
let err = ViewPanel::parse("terminal").unwrap_err();
assert_eq!(err.code, "INVALID");
assert!(err.message.contains("invalid view panel: terminal"));
}
#[test]
fn view_window_label_parser_accepts_stable_and_legacy_labels() {
assert_eq!(
view_panel_from_window_label("view-tickets")
.unwrap()
.as_str(),
"tickets"
);
assert_eq!(
view_panel_from_window_label("view-tickets-0000000000000000000000000000002a")
.unwrap()
.as_str(),
"tickets"
);
assert!(view_panel_from_window_label("main").is_none());
assert!(view_panel_from_window_label("view-unknown").is_none());
assert!(view_panel_from_window_label("view-tickets-not-a-project").is_none());
}
#[test]
fn view_window_snapshot_payload_is_camel_case() {
let payload = ViewWindowSnapshot {
panel: "tickets".to_owned(),
label: "view-tickets".to_owned(),
visible: true,
};
let json = serde_json::to_string(&payload).unwrap();
assert!(json.contains("\"panel\":\"tickets\""), "json was {json}");
assert!(
json.contains("\"label\":\"view-tickets\""),
"json was {json}"
);
assert!(json.contains("\"visible\":true"), "json was {json}");
}
#[test]
fn view_window_lifecycle_payload_is_camel_case() {
let payload = ViewWindowLifecycleEventDto {
kind: "closed",
panel: "tickets".to_owned(),
label: "view-tickets-7".to_owned(),
};
let json = serde_json::to_string(&payload).unwrap();
assert!(!json.contains("projectId"), "json was {json}");
assert!(!json.contains("project_id"), "no snake_case leak: {json}");
}
}
/// `move_tab_to_new_window` — detach a tab into a brand-new OS window.
///
/// Applies the workspace topology change (the tab is *moved*, not duplicated)
/// and opens a fresh [`tauri::WebviewWindow`]. The session-state handoff to that
/// window (rendering the detached tab) is the L11 multi-window UI work; this
/// command provides the backend primitive and the new OS window.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a malformed tab id, `NOT_FOUND` if the
/// tab is unknown to the persisted workspace, `INTERNAL` if the window fails to
/// open).
#[tauri::command]
pub async fn move_tab_to_new_window(
app: tauri::AppHandle,
tab_id: String,
state: State<'_, AppState>,
) -> Result<MoveTabResultDto, ErrorDto> {
let tid = parse_tab_id(&tab_id)?;
let out = state
.move_tab
.execute(MoveTabToNewWindowInput { tab_id: tid })
.await
.map_err(ErrorDto::from)?;
let label = format!("win-{}", out.new_window_id);
tauri::WebviewWindowBuilder::new(&app, &label, tauri::WebviewUrl::App("index.html".into()))
.title("IdeA")
.inner_size(1280.0, 800.0)
.build()
.map_err(|e| ErrorDto {
code: "INTERNAL".to_owned(),
message: e.to_string(),
})?;
Ok(MoveTabResultDto::from(out))
}
// ---------------------------------------------------------------------------
// Skills (L12)
// ---------------------------------------------------------------------------
/// `create_skill` — create a skill in its scope's store.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for an empty name/content or malformed
/// project id, `NOT_FOUND` if the project is unknown, `STORE` on failure).
#[tauri::command]
pub async fn create_skill(
request: CreateSkillRequestDto,
state: State<'_, AppState>,
) -> Result<SkillDto, ErrorDto> {
let project = resolve_project(&request.project_id, &state).await?;
state
.create_skill
.execute(CreateSkillInput {
name: request.name,
// Description is set via the dedicated frontend field (T6); the create
// path stays None for now so the affordance falls back to the body's
// first line (see `Skill::effective_description`).
description: None,
content: request.content,
scope: request.scope,
project_root: project.root,
})
.await
.map(SkillDto::from)
.map_err(ErrorDto::from)
}
/// `update_skill` — replace a skill's Markdown content.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for malformed ids or empty content,
/// `NOT_FOUND` if the project or skill is unknown, `STORE` on failure).
#[tauri::command]
pub async fn update_skill(
request: UpdateSkillRequestDto,
state: State<'_, AppState>,
) -> Result<SkillDto, ErrorDto> {
let project = resolve_project(&request.project_id, &state).await?;
let skill_id = parse_skill_id(&request.skill_id)?;
state
.update_skill
.execute(UpdateSkillInput {
scope: request.scope,
skill_id,
content: request.content,
project_root: project.root,
})
.await
.map(SkillDto::from)
.map_err(ErrorDto::from)
}
/// `list_skills` — list the skills in one scope.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if the
/// project is unknown, `STORE` on failure).
#[tauri::command]
pub async fn list_skills(
project_id: String,
scope: SkillScope,
state: State<'_, AppState>,
) -> Result<SkillListDto, ErrorDto> {
let project = resolve_project(&project_id, &state).await?;
state
.list_skills
.execute(ListSkillsInput {
scope,
project_root: project.root,
})
.await
.map(SkillListDto::from)
.map_err(ErrorDto::from)
}
/// `delete_skill` — remove a skill from its scope's store.
///
/// Agents that referenced it keep their `SkillRef`; injection simply skips the
/// now-absent skill.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for malformed ids, `NOT_FOUND` if the
/// project or skill is unknown, `STORE` on failure).
#[tauri::command]
pub async fn delete_skill(
project_id: String,
scope: SkillScope,
skill_id: String,
state: State<'_, AppState>,
) -> Result<(), ErrorDto> {
let project = resolve_project(&project_id, &state).await?;
let id = parse_skill_id(&skill_id)?;
state
.delete_skill
.execute(DeleteSkillInput {
scope,
skill_id: id,
project_root: project.root,
})
.await
.map_err(ErrorDto::from)
}
/// `assign_skill_to_agent` — record a `SkillRef` in the agent's manifest entry.
/// Idempotent.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for malformed ids, `NOT_FOUND` if the
/// project or agent is unknown, `STORE` on failure).
#[tauri::command]
pub async fn assign_skill_to_agent(
request: AssignSkillRequestDto,
state: State<'_, AppState>,
) -> Result<(), ErrorDto> {
let project = resolve_project(&request.project_id, &state).await?;
let agent_id = parse_agent_id(&request.agent_id)?;
let skill_id = parse_skill_id(&request.skill_id)?;
state
.assign_skill
.execute(AssignSkillToAgentInput {
project,
agent_id,
skill: SkillRef::new(skill_id, request.scope),
})
.await
.map_err(ErrorDto::from)
}
/// `unassign_skill_from_agent` — drop a skill assignment from an agent.
/// Idempotent.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for malformed ids, `NOT_FOUND` if the
/// project or agent is unknown, `STORE` on failure).
#[tauri::command]
pub async fn unassign_skill_from_agent(
request: UnassignSkillRequestDto,
state: State<'_, AppState>,
) -> Result<(), ErrorDto> {
let project = resolve_project(&request.project_id, &state).await?;
let agent_id = parse_agent_id(&request.agent_id)?;
let skill_id = parse_skill_id(&request.skill_id)?;
state
.unassign_skill
.execute(UnassignSkillFromAgentInput {
project,
agent_id,
skill_id,
})
.await
.map_err(ErrorDto::from)
}
// ---------------------------------------------------------------------------
// Memory (LOT A — §14.5.1)
// ---------------------------------------------------------------------------
/// `create_memory` — create a memory note in the project's store.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for an invalid slug/empty fields or
/// malformed project id, `NOT_FOUND` if the project is unknown, `STORE` on
/// failure).
#[tauri::command]
pub async fn create_memory(
request: CreateMemoryRequestDto,
state: State<'_, AppState>,
) -> Result<MemoryDto, ErrorDto> {
let project = resolve_project(&request.project_id, &state).await?;
state
.create_memory
.execute(CreateMemoryInput {
project_root: project.root,
name: request.name,
description: request.description,
r#type: request.r#type,
content: request.content,
})
.await
.map(MemoryDto::from)
.map_err(ErrorDto::from)
}
/// `update_memory` — replace an existing memory note (re-validates invariants).
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for an invalid slug/empty fields,
/// `NOT_FOUND` if the project is unknown, `STORE` on failure).
#[tauri::command]
pub async fn update_memory(
request: UpdateMemoryRequestDto,
state: State<'_, AppState>,
) -> Result<MemoryDto, ErrorDto> {
let project = resolve_project(&request.project_id, &state).await?;
let slug = parse_memory_slug(&request.slug)?;
state
.update_memory
.execute(UpdateMemoryInput {
project_root: project.root,
slug,
description: request.description,
r#type: request.r#type,
content: request.content,
})
.await
.map(MemoryDto::from)
.map_err(ErrorDto::from)
}
/// `list_memories` — list the project's memory notes.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if the
/// project is unknown, `STORE` on failure).
#[tauri::command]
pub async fn list_memories(
project_id: String,
state: State<'_, AppState>,
) -> Result<MemoryListDto, ErrorDto> {
let project = resolve_project(&project_id, &state).await?;
state
.list_memories
.execute(ListMemoriesInput {
project_root: project.root,
})
.await
.map(MemoryListDto::from)
.map_err(ErrorDto::from)
}
/// `get_memory` — read one memory note by slug.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for an invalid slug/id, `NOT_FOUND` if the
/// project or note is unknown, `STORE` on failure).
#[tauri::command]
pub async fn get_memory(
project_id: String,
slug: String,
state: State<'_, AppState>,
) -> Result<MemoryDto, ErrorDto> {
let project = resolve_project(&project_id, &state).await?;
let slug = parse_memory_slug(&slug)?;
state
.get_memory
.execute(GetMemoryInput {
project_root: project.root,
slug,
})
.await
.map(MemoryDto::from)
.map_err(ErrorDto::from)
}
/// `delete_memory` — remove a memory note (and its index row).
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for an invalid slug/id, `NOT_FOUND` if the
/// project or note is unknown, `STORE` on failure).
#[tauri::command]
pub async fn delete_memory(
project_id: String,
slug: String,
state: State<'_, AppState>,
) -> Result<(), ErrorDto> {
let project = resolve_project(&project_id, &state).await?;
let slug = parse_memory_slug(&slug)?;
state
.delete_memory
.execute(DeleteMemoryInput {
project_root: project.root,
slug,
})
.await
.map_err(ErrorDto::from)
}
/// `read_memory_index` — read the structured `MEMORY.md` index.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if the
/// project is unknown, `STORE` on failure).
#[tauri::command]
pub async fn read_memory_index(
project_id: String,
state: State<'_, AppState>,
) -> Result<MemoryIndexDto, ErrorDto> {
let project = resolve_project(&project_id, &state).await?;
state
.read_memory_index
.execute(ReadMemoryIndexInput {
project_root: project.root,
})
.await
.map(MemoryIndexDto::from)
.map_err(ErrorDto::from)
}
/// `recall_memory` — recall the most relevant memory entries for a query within
/// a token budget (LOT B — §14.5.2).
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if the
/// project is unknown, `STORE` on failure). An empty or absent memory and a zero
/// budget both yield an empty list, not an error.
#[tauri::command]
pub async fn recall_memory(
request: RecallMemoryRequestDto,
state: State<'_, AppState>,
) -> Result<MemoryIndexDto, ErrorDto> {
let project = resolve_project(&request.project_id, &state).await?;
state
.recall_memory
.execute(RecallMemoryInput {
project_root: project.root,
text: request.text,
token_budget: request.token_budget,
})
.await
.map(MemoryIndexDto::from)
.map_err(ErrorDto::from)
}
/// `resolve_memory_links` — resolve a note's outgoing `[[slug]]` links.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for an invalid slug/id, `NOT_FOUND` if the
/// project or source note is unknown, `STORE` on failure).
#[tauri::command]
pub async fn resolve_memory_links(
project_id: String,
slug: String,
state: State<'_, AppState>,
) -> Result<MemoryLinksDto, ErrorDto> {
let project = resolve_project(&project_id, &state).await?;
let slug = parse_memory_slug(&slug)?;
state
.resolve_memory_links
.execute(ResolveMemoryLinksInput {
project_root: project.root,
slug,
})
.await
.map(MemoryLinksDto::from)
.map_err(ErrorDto::from)
}
// ---------------------------------------------------------------------------
// Background tasks (B8 — first-class background command)
// ---------------------------------------------------------------------------
/// Maps a background-task port error to the frontend [`ErrorDto`] shape.
fn background_error(err: domain::ports::BackgroundTaskPortError) -> ErrorDto {
use domain::ports::BackgroundTaskPortError as E;
let code = match &err {
E::NotFound => "NOT_FOUND",
E::AlreadyExists | E::Invalid(_) => "INVALID",
E::Runner(_) => "PROCESS",
E::Store(_) => "STORE",
};
ErrorDto {
code: code.to_owned(),
message: err.to_string(),
}
}
/// `spawn_background_command` — start a command-backed first-class background
/// task whose completion is delivered to the owning agent's inbox.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a malformed id/path, `PROCESS` if the
/// command cannot be spawned, `STORE` on persistence failure).
#[tauri::command]
pub async fn spawn_background_command(
request: crate::dto::SpawnBackgroundCommandRequestDto,
state: State<'_, AppState>,
) -> Result<BackgroundTaskDto, ErrorDto> {
let project_id = parse_project_id(&request.project_id)?;
let owner_agent_id = parse_agent_id(&request.owner_agent_id)?;
let cwd = domain::project::ProjectPath::new(request.cwd.clone()).map_err(|_| ErrorDto {
code: "INVALID".to_owned(),
message: format!("invalid working directory: {}", request.cwd),
})?;
let command = domain::ports::SpawnSpec {
command: request.command,
args: request.args,
cwd,
env: request.env,
context_plan: None,
sandbox: None,
};
let wake_policy = if request.record_only {
domain::BackgroundTaskWakePolicy::RecordOnly
} else {
domain::BackgroundTaskWakePolicy::WakeOwner
};
state
.spawn_background_command
.execute(application::SpawnBackgroundCommandInput {
project_id,
owner_agent_id,
label: request.label,
command,
wake_policy,
deadline_ms: request.deadline_ms,
})
.await
.map(|out| BackgroundTaskDto::from(out.task))
.map_err(ErrorDto::from)
}
/// `cancel_background_task` — request cancellation of a running background task.
///
/// The terminal `Cancelled` state is written by the completion sink; this returns
/// the task as currently persisted (absent if it no longer exists).
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `PROCESS`/`STORE` on
/// failure).
#[tauri::command]
pub async fn cancel_background_task(
task_id: String,
state: State<'_, AppState>,
) -> Result<Option<BackgroundTaskDto>, ErrorDto> {
let id = parse_task_id(&task_id)?;
state
.cancel_background_task
.execute(id)
.await
.map(|out| out.task.map(BackgroundTaskDto::from))
.map_err(ErrorDto::from)
}
/// `retry_background_task` — re-run a terminal command task under a **new** task
/// id (the original id is never reused).
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a malformed id or a non-retryable task,
/// `NOT_FOUND` if the task is unknown, `PROCESS`/`STORE` on failure).
#[tauri::command]
pub async fn retry_background_task(
task_id: String,
state: State<'_, AppState>,
) -> Result<BackgroundTaskDto, ErrorDto> {
let id = parse_task_id(&task_id)?;
state
.retry_background_task
.execute(id)
.await
.map(|out| BackgroundTaskDto::from(out.task))
.map_err(ErrorDto::from)
}
/// `list_background_tasks` — read the background-task read-model for a project,
/// optionally narrowed to one owning agent.
///
/// Returns the union of the agent's open tasks and undelivered completions,
/// filtered to `projectId`. Without `agentId`, only the project's undelivered
/// completions are returned (the store cannot enumerate open tasks project-wide).
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `STORE` on failure).
#[tauri::command]
pub async fn list_background_tasks(
project_id: String,
agent_id: Option<String>,
state: State<'_, AppState>,
) -> Result<Vec<BackgroundTaskDto>, ErrorDto> {
let project_id = parse_project_id(&project_id)?;
let agent_id = match &agent_id {
Some(raw) => Some(parse_agent_id(raw)?),
None => None,
};
let store = &state.background_task_store;
let mut by_id: std::collections::HashMap<domain::TaskId, domain::BackgroundTask> =
std::collections::HashMap::new();
if let Some(agent_id) = agent_id {
for task in store
.list_open_for_agent(agent_id)
.await
.map_err(background_error)?
{
by_id.insert(task.id, task);
}
}
for task in store
.list_undelivered_completions()
.await
.map_err(background_error)?
{
by_id.entry(task.id).or_insert(task);
}
let mut tasks: Vec<domain::BackgroundTask> = by_id
.into_values()
.filter(|task| task.project_id == project_id)
.filter(|task| agent_id.map_or(true, |a| task.owner_agent_id == a))
.collect();
tasks.sort_by_key(|task| (task.created_at_ms, task.id));
Ok(tasks.into_iter().map(BackgroundTaskDto::from).collect())
}