Files
IdeA/crates/app-tauri/src/commands.rs
Blomios 785e9935fd feat(memory): config embedders (LOT C2) + suggestion contextuelle (LOT C3) + contexte projet partagé
- LOT C2 (§14.5.3) : use cases de configuration des embedders déclaratifs
  (List/Save/Delete + DescribeEmbedderEngines : modèles ONNX recommandés,
  environnement local détecté, stratégies compilées). UI EmbedderSettings.
- LOT C3 (§14.5.5) : suggestion contextuelle best-effort à l'activation quand la
  mémoire dépasse le budget de recall sans embedder configuré (event
  EmbedderSuggested, anti-spam 1×/session, « ne plus demander »).
- Contexte projet partagé .ideai/CONTEXT.md (model-agnostic) injecté à tous les
  agents/profils au lancement, avant la persona. UI ProjectContextPanel.

Tests : backend workspace vert (0 échec) ; frontend 306/306.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 09:24:51 +02:00

1777 lines
59 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 crate::dto::DismissEmbedderSuggestionRequestDto;
use application::{
AppError, AssignSkillToAgentInput, CloseProjectInput, CreateAgentInput, CreateLayoutInput,
CreateMemoryInput, CreateSkillInput, DeleteAgentInput, DeleteEmbedderProfileInput,
DeleteLayoutInput, DeleteMemoryInput, DeleteSkillInput, DeleteTemplateInput,
DetectAgentDriftInput, GetMemoryInput, GitBranchesInput, GitCheckoutInput, GitCommitInput,
GitGraphInput, GitInitInput, GitLogInput, GitStagePathInput, GitStatusInput,
InspectConversationInput, LaunchAgentInput, ListAgentsInput, ListLayoutsInput,
ListMemoriesInput, ListSkillsInput, LoadLayoutInput, MutateLayoutInput, OpenProjectInput,
ReadAgentContextInput, ReadMemoryIndexInput, ReadProjectContextInput, RecallMemoryInput,
RenameLayoutInput, ResolveMemoryLinksInput, SetActiveLayoutInput, SnapshotRunningAgentsInput,
SyncAgentWithTemplateInput, UnassignSkillFromAgentInput, UpdateAgentContextInput,
UpdateMemoryInput, UpdateProjectContextInput, UpdateSkillInput,
};
use domain::ports::PtyHandle;
use crate::dto::{
parse_agent_id, parse_close_terminal, parse_delete_profile, parse_layout_id, parse_memory_slug,
parse_node_id, parse_profile_id, parse_project_id, parse_session_id, parse_skill_id,
parse_template_id, AgentDriftListDto, AgentDto, AgentListDto, AssignSkillRequestDto,
AttachLiveAgentRequestDto, ConfigureProfilesRequestDto, ConversationDetailsDto,
CreateAgentFromTemplateRequestDto, CreateAgentRequestDto, CreateLayoutRequestDto,
CreateLayoutResultDto, CreateMemoryRequestDto, CreateProjectRequestDto, CreateSkillRequestDto,
CreateTemplateRequestDto, DeleteLayoutRequestDto, DeleteLayoutResultDto,
DetectProfilesRequestDto, DetectProfilesResponseDto, EmbedderEnginesDto, EmbedderProfileDto,
EmbedderProfileListDto, ErrorDto, FirstRunStateDto, GitBranchesDto, GitCheckoutRequestDto,
GitCommitDto, GitCommitListDto, GitCommitRequestDto, GitStageRequestDto, GitStatusListDto,
GraphCommitListDto, HealthRequestDto, HealthResponseDto, InspectConversationRequestDto,
LaunchAgentRequestDto, LayoutDto, LayoutOperationDto, ListLayoutsDto, LiveAgentListDto,
MemoryDto, MemoryIndexDto, MemoryLinksDto, MemoryListDto, OpenTerminalRequestDto, ProfileDto,
ProfileListDto, ProjectDto, ProjectListDto, ReadAgentContextResponseDto, ReattachResultDto,
RecallMemoryRequestDto, RenameLayoutRequestDto, ResizeTerminalRequestDto,
SaveEmbedderProfileRequestDto, SaveProfileRequestDto, SetActiveLayoutRequestDto, SkillDto,
SkillListDto, SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto, TemplateListDto,
TerminalClosedDto, TerminalSessionDto, UnassignSkillRequestDto, UpdateAgentContextRequestDto,
UpdateMemoryRequestDto, UpdateProjectContextRequestDto, UpdateSkillRequestDto,
UpdateTemplateRequestDto, WriteTerminalRequestDto,
};
use crate::pty::{PtyBridge, PtyChunk};
use crate::state::AppState;
use domain::{SkillRef, SkillScope};
/// `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> {
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> {
let id = parse_project_id(&project_id)?;
let output = state
.open_project
.execute(OpenProjectInput { project_id: id })
.await
.map_err(ErrorDto::from)?;
// (Re)start the orchestrator watcher for this project (idempotent, §14.3).
state.ensure_orchestrator_watch(&output.project);
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)
}
// ---------------------------------------------------------------------------
// 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()?;
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
}
/// `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.
///
/// # 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)
}
// ---------------------------------------------------------------------------
// 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)
}
/// `list_live_agents` — list the agents that currently own a live PTY session
/// and the cell hosting each, so the UI can disable an agent already running in
/// another cell (the "one live session per agent" invariant).
///
/// `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)?;
Ok(LiveAgentListDto::from_pairs(
state.terminal_sessions.live_agents(),
))
}
/// `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<LiveAgentListDto, 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)?;
let session = state
.terminal_sessions
.rebind_agent_node(&agent_id, node_id)
.ok_or_else(|| AppError::NotFound(format!("running session for agent {agent_id}")))?;
Ok(LiveAgentListDto::from_pairs(vec![(
agent_id,
session.node_id,
session.id,
)]))
}
/// `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()?;
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(),
})
.await
.map_err(ErrorDto::from)?;
let session_id = output.session.id;
// Register the xterm output channel for this session.
let gen = 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 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;
}
}
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))
}
// ---------------------------------------------------------------------------
// 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;
/// `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,
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)
}