Agents for developpement added + frontend add + backend added. Git viewer created + agent and template creator + layout and project creator
1122 lines
36 KiB
Rust
1122 lines
36 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 tauri::ipc::Channel;
|
|
use tauri::State;
|
|
|
|
use application::{
|
|
AppError, CloseProjectInput, CreateAgentInput, CreateLayoutInput, DeleteAgentInput,
|
|
DeleteLayoutInput, DeleteTemplateInput, DetectAgentDriftInput, GitBranchesInput,
|
|
GitCheckoutInput, GitCommitInput, GitGraphInput, GitInitInput, GitLogInput, GitStagePathInput,
|
|
GitStatusInput, LaunchAgentInput, ListAgentsInput, ListLayoutsInput, LoadLayoutInput,
|
|
MutateLayoutInput, OpenProjectInput, ReadAgentContextInput, RenameLayoutInput,
|
|
SetActiveLayoutInput, SyncAgentWithTemplateInput, UpdateAgentContextInput,
|
|
};
|
|
use domain::ports::PtyHandle;
|
|
|
|
use crate::dto::{
|
|
parse_agent_id, parse_close_terminal, parse_delete_profile, parse_layout_id, parse_profile_id,
|
|
parse_project_id, parse_session_id, parse_template_id, AgentDriftListDto, AgentDto,
|
|
AgentListDto, ConfigureProfilesRequestDto, CreateAgentFromTemplateRequestDto,
|
|
CreateAgentRequestDto, CreateLayoutRequestDto, CreateLayoutResultDto, CreateProjectRequestDto,
|
|
CreateTemplateRequestDto, DeleteLayoutRequestDto, DeleteLayoutResultDto,
|
|
DetectProfilesRequestDto, DetectProfilesResponseDto, ErrorDto, FirstRunStateDto,
|
|
GitBranchesDto, GitCheckoutRequestDto, GitCommitDto, GitCommitListDto, GitCommitRequestDto,
|
|
GitStageRequestDto, GitStatusListDto, GraphCommitListDto, HealthRequestDto, HealthResponseDto,
|
|
LaunchAgentRequestDto, LayoutDto, LayoutOperationDto, ListLayoutsDto, OpenTerminalRequestDto,
|
|
ProfileDto, ProfileListDto, ProjectDto, ProjectListDto, ReadAgentContextResponseDto,
|
|
RenameLayoutRequestDto, ResizeTerminalRequestDto, SaveProfileRequestDto,
|
|
SetActiveLayoutRequestDto, SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto,
|
|
TemplateListDto, TerminalClosedDto, TerminalSessionDto, UpdateAgentContextRequestDto,
|
|
UpdateTemplateRequestDto, WriteTerminalRequestDto,
|
|
};
|
|
use crate::pty::{PtyBridge, PtyChunk};
|
|
use crate::state::AppState;
|
|
|
|
/// `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)
|
|
}
|
|
|
|
/// `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> {
|
|
state
|
|
.create_project
|
|
.execute(request.into())
|
|
.await
|
|
.map(ProjectDto::from)
|
|
.map_err(ErrorDto::from)
|
|
}
|
|
|
|
/// `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> {
|
|
let id = parse_project_id(&project_id)?;
|
|
state
|
|
.open_project
|
|
.execute(OpenProjectInput { project_id: id })
|
|
.await
|
|
.map(ProjectDto::from)
|
|
.map_err(ErrorDto::from)
|
|
}
|
|
|
|
/// `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)?;
|
|
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)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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.
|
|
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 the bridge channel is gone.
|
|
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 (process exited): drop the channel registration.
|
|
bridge.unregister(&session_id);
|
|
});
|
|
}
|
|
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()?;
|
|
state
|
|
.write_terminal
|
|
.execute(input)
|
|
.map_err(ErrorDto::from)
|
|
}
|
|
|
|
/// `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
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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.
|
|
///
|
|
/// # Errors
|
|
/// Returns an [`ErrorDto`] (`INVALID` for malformed id, `NOT_FOUND` if the
|
|
/// project or layout is unknown).
|
|
#[tauri::command]
|
|
pub async fn set_active_layout(
|
|
request: SetActiveLayoutRequestDto,
|
|
state: State<'_, AppState>,
|
|
) -> Result<(), 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_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
|
|
/// (Claude/Codex/Gemini/Aider).
|
|
///
|
|
/// # 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)
|
|
}
|
|
|
|
/// `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/custom profiles,
|
|
/// closing the first run.
|
|
///
|
|
/// # 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)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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)
|
|
}
|
|
|
|
/// `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)
|
|
}
|
|
|
|
/// `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)?;
|
|
|
|
let output = state
|
|
.launch_agent
|
|
.execute(LaunchAgentInput {
|
|
project,
|
|
agent_id,
|
|
rows: request.rows,
|
|
cols: request.cols,
|
|
node_id: None,
|
|
})
|
|
.await
|
|
.map_err(ErrorDto::from)?;
|
|
|
|
let session_id = output.session.id;
|
|
|
|
// Register the xterm output channel for this session.
|
|
state.pty_bridge.register(session_id, on_output);
|
|
|
|
// 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 the bridge channel is gone.
|
|
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;
|
|
}
|
|
}
|
|
bridge.unregister(&session_id);
|
|
});
|
|
}
|
|
Err(e) => {
|
|
state.pty_bridge.unregister(&session_id);
|
|
return Err(ErrorDto::from(AppError::from(e)));
|
|
}
|
|
}
|
|
|
|
Ok(TerminalSessionDto::from(output))
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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 application::MoveTabToNewWindowInput;
|
|
use crate::dto::{parse_tab_id, MoveTabResultDto};
|
|
|
|
/// `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))
|
|
}
|