Détache les vues dans de vraies fenêtres système Tauri (multi-écran, fullscreen). Backend : commandes de gestion de WebviewWindow, capabilities et composition root. Frontend : nouveau port WindowGateway et son adaptateur window, entrée panel-only ViewWindow/ViewPanelBody, détachement câblé dans ProjectsView. QA vert : app-tauri 63, frontend typecheck + vitest 546/546. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2987 lines
107 KiB
Rust
2987 lines
107 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, ListLayoutsInput, ListMemoriesInput,
|
|
ListResumableAgentsInput, ListSkillsInput, LiveSessions, LoadLayoutInput, McpRuntime,
|
|
MutateLayoutInput, OpenProjectInput, ReadAgentContextInput, ReadConversationPageInput,
|
|
ReadMemoryIndexInput, ReadProjectContextInput, RecallMemoryInput, ReconcileLayoutsInput,
|
|
ReconcileLiveStateInput, RenameLayoutInput, ResolveAgentPermissionsInput,
|
|
ResolveMemoryLinksInput, RotateConversationLogInput, SetActiveLayoutInput,
|
|
SnapshotRunningAgentsInput, StopLiveAgentInput, SyncAgentWithTemplateInput,
|
|
UnassignSkillFromAgentInput, UpdateAgentContextInput, UpdateAgentPermissionsInput,
|
|
UpdateMemoryInput, UpdateProjectContextInput, UpdateProjectPermissionsInput, 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_task_id, parse_template_id, parse_ticket_id, AgentDriftListDto, AgentDto, AgentListDto,
|
|
AssignSkillRequestDto, AttachLiveAgentRequestDto, AttachLiveAgentResponseDto,
|
|
BackgroundTaskDto, ChangeAgentProfileDto, ChangeAgentProfileRequestDto,
|
|
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, OpenTerminalRequestDto, ProfileDto,
|
|
ProfileListDto, ProjectDto, ProjectListDto, ProjectPermissionsDto, ProjectWorkStateDto,
|
|
ReadAgentContextResponseDto, ReadConversationPageRequestDto, ReattachChatDto,
|
|
ReattachResultDto, RecallMemoryRequestDto, RenameLayoutRequestDto, ReplyChunk,
|
|
ResizeTerminalRequestDto, ResolveAgentPermissionsRequestDto, ResumableAgentListDto,
|
|
SaveEmbedderProfileRequestDto, 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::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)?;
|
|
// 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)
|
|
}
|
|
|
|
/// `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)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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 of this session once; a `RateLimited` turn event then feeds the service
|
|
// (détecter→planifier). DORMANT en composition B-2 (aucune session structurée
|
|
// vivante) mais câblé pour forward-compat. `conversation_id` non disponible ici ⇒
|
|
// `None` (acceptable LS7).
|
|
let service = std::sync::Arc::clone(&state.session_limit_service);
|
|
let meta = state.structured_sessions.meta_for_session(&sid);
|
|
std::thread::spawn(move || {
|
|
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)) = meta {
|
|
service.on_rate_limited(agent_id, node_id, None, *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;
|
|
};
|
|
// `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);
|
|
}
|
|
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";
|
|
|
|
/// Response returned by `open_view_window`.
|
|
#[derive(Debug, Clone, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct OpenViewWindowResponseDto {
|
|
/// Stable Tauri window label for this `(panel, projectId)`.
|
|
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, projectId)`.
|
|
pub label: String,
|
|
/// `true` when a live window was found and close was requested.
|
|
pub closed: 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,
|
|
/// Project id rendered by the detached window.
|
|
pub project_id: String,
|
|
/// Stable Tauri window label.
|
|
pub label: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
enum ViewPanel {
|
|
Projects,
|
|
Context,
|
|
Work,
|
|
Tickets,
|
|
Agents,
|
|
Templates,
|
|
Skills,
|
|
Permissions,
|
|
Memory,
|
|
Git,
|
|
}
|
|
|
|
impl ViewPanel {
|
|
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}"),
|
|
}),
|
|
}
|
|
}
|
|
|
|
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",
|
|
}
|
|
}
|
|
|
|
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",
|
|
}
|
|
}
|
|
}
|
|
|
|
fn view_window_label(panel: ViewPanel, project_id: domain::ProjectId) -> String {
|
|
format!("view-{}-{}", panel.as_str(), project_id.as_uuid().simple())
|
|
}
|
|
|
|
fn view_window_url(panel: ViewPanel, project_id: domain::ProjectId) -> String {
|
|
format!("index.html?panel={}&project={}", panel.as_str(), project_id)
|
|
}
|
|
|
|
fn emit_view_window_lifecycle(
|
|
app: &AppHandle,
|
|
kind: &'static str,
|
|
panel: ViewPanel,
|
|
project_id: domain::ProjectId,
|
|
label: &str,
|
|
) {
|
|
let _ = app.emit(
|
|
VIEW_WINDOW_LIFECYCLE_EVENT,
|
|
ViewWindowLifecycleEventDto {
|
|
kind,
|
|
panel: panel.as_str().to_owned(),
|
|
project_id: project_id.to_string(),
|
|
label: label.to_owned(),
|
|
},
|
|
);
|
|
}
|
|
|
|
/// `open_view_window` — open or focus a detached OS window for one project view.
|
|
///
|
|
/// The window is a normal decorated, resizable system window. Its webview loads
|
|
/// `index.html?panel=<panel>&project=<projectId>` so the frontend can boot a
|
|
/// panel-only shell with the regular gateways and read models.
|
|
///
|
|
/// # Errors
|
|
/// Returns an [`ErrorDto`] (`INVALID` for an unknown panel or malformed project
|
|
/// id, `NOT_FOUND`/`STORE` if the project cannot be loaded, `INTERNAL` if Tauri
|
|
/// fails to create/focus the window).
|
|
#[tauri::command]
|
|
pub async fn open_view_window(
|
|
app: AppHandle,
|
|
panel: String,
|
|
project_id: String,
|
|
state: State<'_, AppState>,
|
|
) -> Result<OpenViewWindowResponseDto, ErrorDto> {
|
|
let panel = ViewPanel::parse(&panel)?;
|
|
let project = resolve_project(&project_id, &state).await?;
|
|
let project_id = project.id;
|
|
let label = view_window_label(panel, project_id);
|
|
let url = view_window_url(panel, project_id);
|
|
|
|
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, project_id, &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, project_id, &event_label);
|
|
}
|
|
});
|
|
emit_view_window_lifecycle(&app, "opened", panel, project_id, &label);
|
|
|
|
Ok(OpenViewWindowResponseDto {
|
|
label,
|
|
url,
|
|
already_open: false,
|
|
})
|
|
}
|
|
|
|
/// `close_view_window` — request closing the detached OS window for one project view.
|
|
///
|
|
/// # Errors
|
|
/// Returns an [`ErrorDto`] (`INVALID` for an unknown panel or malformed project
|
|
/// id, `INTERNAL` if Tauri fails to close the window).
|
|
#[tauri::command]
|
|
pub async fn close_view_window(
|
|
app: AppHandle,
|
|
panel: String,
|
|
project_id: String,
|
|
) -> Result<CloseViewWindowResponseDto, ErrorDto> {
|
|
let panel = ViewPanel::parse(&panel)?;
|
|
let project_id = parse_project_id(&project_id)?;
|
|
let label = view_window_label(panel, project_id);
|
|
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::*;
|
|
use domain::ProjectId;
|
|
use uuid::Uuid;
|
|
|
|
#[test]
|
|
fn view_window_label_and_url_are_stable() {
|
|
let project_id = ProjectId::from_uuid(Uuid::from_u128(0x123));
|
|
let panel = ViewPanel::Tickets;
|
|
|
|
assert_eq!(
|
|
view_window_label(panel, project_id),
|
|
"view-tickets-00000000000000000000000000000123"
|
|
);
|
|
assert_eq!(
|
|
view_window_url(panel, project_id),
|
|
"index.html?panel=tickets&project=00000000-0000-0000-0000-000000000123"
|
|
);
|
|
}
|
|
|
|
#[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_lifecycle_payload_is_camel_case() {
|
|
let payload = ViewWindowLifecycleEventDto {
|
|
kind: "closed",
|
|
panel: "tickets".to_owned(),
|
|
project_id: Uuid::from_u128(7).to_string(),
|
|
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())
|
|
}
|