feat: add main features

Agents for developpement added + frontend add + backend added. Git viewer created + agent and template creator + layout and project creator
This commit is contained in:
2026-06-06 01:27:01 +02:00
parent 55b3bee2c8
commit 307ae71857
273 changed files with 48740 additions and 0 deletions

View File

@ -0,0 +1,35 @@
[package]
name = "app-tauri"
version = "0.1.0"
edition.workspace = true
license.workspace = true
rust-version.workspace = true
description = "IdeA — Tauri v2 shell: composition root (DI), IPC commands/events, PTY↔Channel bridge."
# The library carries all the wiring so it is unit-testable; the binary is a
# thin entry point.
[lib]
name = "app_tauri_lib"
crate-type = ["lib", "cdylib", "staticlib"]
[[bin]]
name = "app-tauri"
path = "src/main.rs"
[build-dependencies]
tauri-build = { workspace = true }
[dependencies]
domain = { workspace = true }
application = { workspace = true }
infrastructure = { workspace = true }
tauri = { workspace = true }
tauri-plugin-dialog = { workspace = true }
tokio = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
uuid = { workspace = true }
[dev-dependencies]
uuid = { workspace = true }

View File

@ -0,0 +1,3 @@
fn main() {
tauri_build::build();
}

View File

@ -0,0 +1,7 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Default capability set for the IdeA main window.",
"windows": ["main"],
"permissions": ["core:default", "dialog:allow-open"]
}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1 @@
{"default":{"identifier":"default","description":"Default capability set for the IdeA main window.","local":true,"windows":["main"],"permissions":["core:default","dialog:allow-open"]}}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<background android:drawable="@color/ic_launcher_background"/>
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#fff</color>
</resources>

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 729 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

File diff suppressed because it is too large Load Diff

1332
crates/app-tauri/src/dto.rs Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,186 @@
//! `TauriEventRelay` — bridges the domain [`EventBus`] to Tauri events
//! (backend → frontend push channel, ARCHITECTURE §2 "Events").
//!
//! The relay subscribes to the bus and re-emits each [`DomainEvent`] as a Tauri
//! event named [`DOMAIN_EVENT`], carrying a serialisable [`DomainEventDto`]
//! payload (the domain event itself is deliberately not `Serialize`; the wire
//! format is owned here, the infrastructure/presentation layer).
//!
//! High-frequency `PtyOutput` is intentionally *not* relayed through this global
//! event; it goes through per-session [`crate::pty::PtyBridge`] channels instead.
use serde::Serialize;
use tauri::{AppHandle, Emitter};
use domain::events::DomainEvent;
use infrastructure::TokioBroadcastEventBus;
/// Name of the Tauri event carrying relayed [`DomainEvent`]s.
pub const DOMAIN_EVENT: &str = "domain://event";
/// Serialisable mirror of [`DomainEvent`] for the IPC wire (camelCase, tagged).
///
/// `type` is the discriminant; payload fields are flattened per variant. This is
/// the single owner of the event wire format on the backend side.
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum DomainEventDto {
/// A project was created.
#[serde(rename_all = "camelCase")]
ProjectCreated {
/// Project id (UUID string).
project_id: String,
},
/// An agent was launched.
#[serde(rename_all = "camelCase")]
AgentLaunched {
/// Agent id.
agent_id: String,
/// Session id.
session_id: String,
},
/// An agent exited.
#[serde(rename_all = "camelCase")]
AgentExited {
/// Agent id.
agent_id: String,
/// Exit code.
code: i32,
},
/// A template was updated.
#[serde(rename_all = "camelCase")]
TemplateUpdated {
/// Template id.
template_id: String,
/// New version.
version: u64,
},
/// A synchronized agent drifted from its template.
#[serde(rename_all = "camelCase")]
AgentDriftDetected {
/// Agent id.
agent_id: String,
/// Current version.
from: u64,
/// Available version.
to: u64,
},
/// A synchronized agent was brought up to date.
#[serde(rename_all = "camelCase")]
AgentSynced {
/// Agent id.
agent_id: String,
/// Version synced to.
to: u64,
},
/// A tab's layout changed.
#[serde(rename_all = "camelCase")]
LayoutChanged {
/// Project id.
project_id: String,
},
/// A remote connection was established.
#[serde(rename_all = "camelCase")]
RemoteConnected {
/// Project id.
project_id: String,
},
/// Git state changed.
#[serde(rename_all = "camelCase")]
GitStateChanged {
/// Project id.
project_id: String,
},
/// Raw PTY output (normally routed to a per-session channel, not here).
#[serde(rename_all = "camelCase")]
PtyOutput {
/// Session id.
session_id: String,
/// Output bytes.
bytes: Vec<u8>,
},
}
impl From<&DomainEvent> for DomainEventDto {
fn from(e: &DomainEvent) -> Self {
match e {
DomainEvent::ProjectCreated { project_id } => Self::ProjectCreated {
project_id: project_id.to_string(),
},
DomainEvent::AgentLaunched {
agent_id,
session_id,
} => Self::AgentLaunched {
agent_id: agent_id.to_string(),
session_id: session_id.to_string(),
},
DomainEvent::AgentExited { agent_id, code } => Self::AgentExited {
agent_id: agent_id.to_string(),
code: *code,
},
DomainEvent::TemplateUpdated {
template_id,
version,
} => Self::TemplateUpdated {
template_id: template_id.to_string(),
version: version.get(),
},
DomainEvent::AgentDriftDetected {
agent_id,
from,
to,
} => Self::AgentDriftDetected {
agent_id: agent_id.to_string(),
from: from.get(),
to: to.get(),
},
DomainEvent::AgentSynced { agent_id, to } => Self::AgentSynced {
agent_id: agent_id.to_string(),
to: to.get(),
},
DomainEvent::LayoutChanged { project_id } => Self::LayoutChanged {
project_id: project_id.to_string(),
},
DomainEvent::RemoteConnected { project_id } => Self::RemoteConnected {
project_id: project_id.to_string(),
},
DomainEvent::GitStateChanged { project_id } => Self::GitStateChanged {
project_id: project_id.to_string(),
},
DomainEvent::PtyOutput { session_id, bytes } => Self::PtyOutput {
session_id: session_id.to_string(),
bytes: bytes.clone(),
},
}
}
}
/// Subscribes the relay to the bus and spawns a background task that forwards
/// every [`DomainEvent`] to the frontend as a [`DOMAIN_EVENT`] Tauri event.
///
/// Uses the bus's raw async broadcast receiver so the relay runs cooperatively
/// on the Tokio runtime (no blocking thread). Returns immediately; the spawned
/// task lives for the duration of the app.
pub fn spawn_relay(app: AppHandle, bus: &TokioBroadcastEventBus) {
use tokio::sync::broadcast::error::RecvError;
let mut rx = bus.raw_receiver();
tauri::async_runtime::spawn(async move {
loop {
match rx.recv().await {
Ok(event) => {
// Skip high-frequency PTY output on the global channel.
if matches!(event, DomainEvent::PtyOutput { .. }) {
continue;
}
let dto = DomainEventDto::from(&event);
let _ = app.emit(DOMAIN_EVENT, dto);
}
// The bus dropped some events for this slow receiver; keep going.
Err(RecvError::Lagged(_)) => continue,
// The bus was dropped (app shutting down); stop the relay.
Err(RecvError::Closed) => break,
}
}
});
}

103
crates/app-tauri/src/lib.rs Normal file
View File

@ -0,0 +1,103 @@
//! # IdeA — `app-tauri` (presentation / driving adapter + composition root)
//!
//! This crate is the **only** place that knows every other crate. It:
//! - builds the concrete adapters and injects them into use cases
//! ([`state::AppState`], the composition root),
//! - exposes `#[tauri::command]` handlers ([`commands`]) mapping DTOs ↔ use cases,
//! - relays domain events to the frontend ([`events::TauriEventRelay`]),
//! - hosts the generic PTY↔Channel bridge ([`pty::PtyBridge`]) for L3.
//!
//! The wiring lives in the library (testable) and `main.rs` is a thin shim.
#![forbid(unsafe_code)]
#![warn(missing_docs)]
pub mod commands;
pub mod dto;
pub mod events;
pub mod pty;
pub mod state;
use tauri::Manager;
use state::AppState;
/// Builds and runs the Tauri application.
///
/// Sets up the composition root (resolving the app-data directory via the Tauri
/// path API), registers commands, spawns the event relay, and starts the main
/// window.
///
/// # Panics
/// Panics if the Tauri application fails to build or run (no window/webview), or
/// if the app-data directory cannot be resolved.
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
.setup(|app| {
// Resolve the machine-local IDE data directory (ARCHITECTURE §9.2)
// and build the composition root once the app handle exists, so the
// stores receive a concrete path without ever touching Tauri.
let app_data_dir = app
.path()
.app_data_dir()
.expect("failed to resolve the app data directory");
let app_state = AppState::build(app_data_dir);
// Wire the domain event bus → Tauri events relay.
events::spawn_relay(app.handle().clone(), &app_state.event_bus);
app.manage(app_state);
Ok(())
})
.invoke_handler(tauri::generate_handler![
commands::health,
commands::create_project,
commands::open_project,
commands::close_project,
commands::list_projects,
commands::open_terminal,
commands::write_terminal,
commands::resize_terminal,
commands::close_terminal,
commands::load_layout,
commands::mutate_layout,
commands::list_layouts,
commands::create_layout,
commands::rename_layout,
commands::delete_layout,
commands::set_active_layout,
commands::first_run_state,
commands::reference_profiles,
commands::detect_profiles,
commands::list_profiles,
commands::save_profile,
commands::delete_profile,
commands::configure_profiles,
commands::create_agent,
commands::list_agents,
commands::read_agent_context,
commands::update_agent_context,
commands::delete_agent,
commands::launch_agent,
commands::create_template,
commands::update_template,
commands::list_templates,
commands::delete_template,
commands::create_agent_from_template,
commands::detect_agent_drift,
commands::sync_agent_with_template,
commands::git_status,
commands::git_stage,
commands::git_unstage,
commands::git_commit,
commands::git_branches,
commands::git_checkout,
commands::git_log,
commands::git_init,
commands::git_graph,
commands::move_tab_to_new_window,
])
.run(tauri::generate_context!())
.expect("error while running IdeA Tauri application");
}

View File

@ -0,0 +1,16 @@
// Prevents an extra console window on Windows in release builds.
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
// WebKitGTK's DMABUF renderer causes a blank/white window on many Linux
// setups (recent Mesa/Nvidia drivers, common on Arch). Disable it before
// the webview initializes, unless the user has explicitly set the variable.
#[cfg(target_os = "linux")]
{
if std::env::var_os("WEBKIT_DISABLE_DMABUF_RENDERER").is_none() {
std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1");
}
}
app_tauri_lib::run();
}

View File

@ -0,0 +1,84 @@
//! Generic **PTY ↔ Tauri Channel** bridge infrastructure.
//!
//! ARCHITECTURE §2 decides that high-frequency PTY byte streams travel over
//! per-session [`tauri::ipc::Channel`]s rather than global events, for
//! throughput and isolation. This module provides the transport-side plumbing
//! that L3 will plug a real `PtyPort` into; here there is **no real PTY** yet —
//! only the registry + the abstraction that routes byte chunks to the right
//! frontend channel.
//!
//! Design:
//! - The frontend opens a terminal and passes a [`tauri::ipc::Channel`] for that
//! session. The backend registers it in [`PtyBridge`] keyed by `SessionId`.
//! - Whatever produces output (the PTY adapter in L3) calls
//! [`PtyBridge::send_output`], which forwards the bytes on the matching
//! channel. Bytes are sent as-is; the frontend xterm wrapper consumes them.
//! - [`PtyBridge::unregister`] tears the channel down on terminal close.
use std::collections::HashMap;
use std::sync::Mutex;
use tauri::ipc::Channel;
use domain::ids::SessionId;
/// A chunk of PTY output bytes destined for a specific session's channel.
///
/// Sent as a raw byte vector; serde encodes it for the IPC channel. Kept as a
/// distinct type so the wire shape can evolve (e.g. add a sequence number for
/// backpressure handling) without touching call sites.
pub type PtyChunk = Vec<u8>;
/// Registry mapping live terminal sessions to their output [`Channel`].
///
/// Thread-safe; cloned `Arc<PtyBridge>` is held in [`crate::state::AppState`].
#[derive(Default)]
pub struct PtyBridge {
channels: Mutex<HashMap<SessionId, Channel<PtyChunk>>>,
}
impl PtyBridge {
/// Creates an empty bridge.
#[must_use]
pub fn new() -> Self {
Self {
channels: Mutex::new(HashMap::new()),
}
}
/// Registers the output channel for a session (called when a terminal is
/// opened, from a `#[tauri::command]` that receives the `Channel` argument).
pub fn register(&self, session: SessionId, channel: Channel<PtyChunk>) {
if let Ok(mut map) = self.channels.lock() {
map.insert(session, channel);
}
}
/// Removes a session's channel (terminal closed).
pub fn unregister(&self, session: &SessionId) {
if let Ok(mut map) = self.channels.lock() {
map.remove(session);
}
}
/// Forwards a chunk of output bytes to a session's channel.
///
/// Returns `true` if the chunk was delivered, `false` if no channel is
/// registered for the session (e.g. already closed). In L3 the PTY adapter's
/// output stream drives this.
pub fn send_output(&self, session: &SessionId, chunk: PtyChunk) -> bool {
let Ok(map) = self.channels.lock() else {
return false;
};
match map.get(session) {
Some(channel) => channel.send(chunk).is_ok(),
None => false,
}
}
/// Number of currently-registered sessions (handy for tests/diagnostics).
#[must_use]
pub fn active_sessions(&self) -> usize {
self.channels.lock().map(|m| m.len()).unwrap_or(0)
}
}

View File

@ -0,0 +1,428 @@
//! Managed application state — the product of the **composition root**.
//!
//! The composition root ([`build_app_state`]) is the *single* place that
//! constructs concrete adapters (`new ConcreteAdapter`) and injects them as
//! `Arc<dyn Port>` into the use cases (ARCHITECTURE §1.1, §10). The use cases
//! are then exposed through `tauri::State<AppState>` to the command handlers.
use std::path::PathBuf;
use std::sync::Arc;
use application::{
CloseProject, CloseTab, CloseTerminal, ConfigureProfiles, CreateAgentFromScratch,
CreateAgentFromTemplate, CreateLayout, CreateProject, CreateTemplate, DeleteAgent,
DeleteLayout, DeleteProfile, DeleteTemplate, DetectAgentDrift, DetectProfiles, FirstRunState,
GitBranches, GitCheckout, GitCommit, GitGraph, GitInit, GitLog, GitStage, GitStatus, GitUnstage,
HealthUseCase, LaunchAgent, ListAgents, ListLayouts, ListProfiles, ListProjects, ListTemplates,
LoadLayout, MoveTabToNewWindow, MutateLayout, OpenProject, OpenTerminal, ReadAgentContext,
ReferenceProfiles, RenameLayout, ResizeTerminal, SaveProfile, SetActiveLayout,
SyncAgentWithTemplate, TerminalSessions, UpdateAgentContext, UpdateTemplate, WriteToTerminal,
};
use domain::ports::{
AgentContextStore, AgentRuntime, Clock, EventBus, FileSystem, GitPort, IdGenerator,
ProcessSpawner, ProfileStore, ProjectStore, PtyPort, TemplateStore,
};
use infrastructure::{
CliAgentRuntime, FsProfileStore, FsProjectStore, FsTemplateStore, Git2Repository,
IdeaiContextStore, LocalFileSystem, LocalProcessSpawner, PortablePtyAdapter, SystemClock,
TokioBroadcastEventBus, UuidGenerator,
};
use crate::pty::PtyBridge;
/// Everything the IPC layer needs at runtime, managed by Tauri.
///
/// Use cases are stored behind `Arc` so handlers clone cheaply. The concrete
/// adapters are owned here and never leak past the composition root as concrete
/// types — downstream code only sees the `Arc<dyn Port>` held inside the use
/// cases.
pub struct AppState {
/// Trivial health use case validating the end-to-end wiring.
pub health: Arc<HealthUseCase>,
/// Create a project (init `.ideai/`, register it).
pub create_project: Arc<CreateProject>,
/// Open a project (load meta + manifest).
pub open_project: Arc<OpenProject>,
/// Close a project (persist state).
pub close_project: Arc<CloseProject>,
/// Close a tab.
pub close_tab: Arc<CloseTab>,
/// List known projects.
pub list_projects: Arc<ListProjects>,
/// Open a terminal (spawn PTY, register session).
pub open_terminal: Arc<OpenTerminal>,
/// Write keystrokes to a terminal.
pub write_terminal: Arc<WriteToTerminal>,
/// Resize a terminal.
pub resize_terminal: Arc<ResizeTerminal>,
/// Close a terminal (kill PTY).
pub close_terminal: Arc<CloseTerminal>,
/// Load a project's persisted layout tree.
pub load_layout: Arc<LoadLayout>,
/// Mutate + persist a project's layout tree.
pub mutate_layout: Arc<MutateLayout>,
/// List all named layouts for a project (#4).
pub list_layouts: Arc<ListLayouts>,
/// Create a new named layout (#4).
pub create_layout: Arc<CreateLayout>,
/// Rename a named layout (#4).
pub rename_layout: Arc<RenameLayout>,
/// Delete a named layout (#4).
pub delete_layout: Arc<DeleteLayout>,
/// Set the active named layout (#4).
pub set_active_layout: Arc<SetActiveLayout>,
/// Detect which candidate profiles' CLIs are installed (first-run).
pub detect_profiles: Arc<DetectProfiles>,
/// List configured profiles.
pub list_profiles: Arc<ListProfiles>,
/// Save (upsert) a profile.
pub save_profile: Arc<SaveProfile>,
/// Delete a profile.
pub delete_profile: Arc<DeleteProfile>,
/// Persist the batch of chosen profiles (closes the first run).
pub configure_profiles: Arc<ConfigureProfiles>,
/// Expose the pre-filled reference catalogue.
pub reference_profiles: Arc<ReferenceProfiles>,
/// Whether the first-run wizard should show + the reference catalogue.
pub first_run_state: Arc<FirstRunState>,
/// The local PTY adapter, kept port-typed so the presentation layer can
/// `subscribe_output` to pump bytes into the [`PtyBridge`] (it owns transport).
pub pty_port: Arc<dyn PtyPort>,
/// Active-terminal registry shared by the terminal use cases.
pub terminal_sessions: Arc<TerminalSessions>,
/// The domain event bus (also handed to the event relay).
pub event_bus: Arc<TokioBroadcastEventBus>,
/// Generic PTY↔Channel bridge registry (consumed by L3).
pub pty_bridge: Arc<PtyBridge>,
// --- Agents (L6) ---
/// Create a project agent from scratch.
pub create_agent: Arc<CreateAgentFromScratch>,
/// List a project's agents.
pub list_agents: Arc<ListAgents>,
/// Read an agent's Markdown context.
pub read_agent_context: Arc<ReadAgentContext>,
/// Overwrite an agent's Markdown context.
pub update_agent_context: Arc<UpdateAgentContext>,
/// Delete an agent from the manifest.
pub delete_agent: Arc<DeleteAgent>,
/// Launch an agent (spawn PTY, apply injection strategy).
pub launch_agent: Arc<LaunchAgent>,
/// Project registry — used by agent commands to resolve a `Project` from an id.
pub project_store: Arc<dyn ProjectStore>,
// --- Windows (L10) ---
/// Detach a tab into a new OS window (persists the workspace topology).
pub move_tab: Arc<MoveTabToNewWindow>,
// --- Templates & sync (L7) ---
/// Create a template in the global store.
pub create_template: Arc<CreateTemplate>,
/// Update a template's content (bumps version).
pub update_template: Arc<UpdateTemplate>,
/// List all templates in the global store.
pub list_templates: Arc<ListTemplates>,
/// Delete a template from the global store.
pub delete_template: Arc<DeleteTemplate>,
/// Create an agent from a template.
pub create_agent_from_template: Arc<CreateAgentFromTemplate>,
/// Detect which synchronized agents are behind their template.
pub detect_agent_drift: Arc<DetectAgentDrift>,
/// Apply a template update to a synchronized agent.
pub sync_agent_with_template: Arc<SyncAgentWithTemplate>,
// --- Git (L8) ---
/// Report the working-tree status of a repository.
pub git_status: Arc<GitStatus>,
/// Stage a path.
pub git_stage: Arc<GitStage>,
/// Unstage a path.
pub git_unstage: Arc<GitUnstage>,
/// Create a commit.
pub git_commit: Arc<GitCommit>,
/// List branches.
pub git_branches: Arc<GitBranches>,
/// Check out a branch.
pub git_checkout: Arc<GitCheckout>,
/// Return the recent commit log.
pub git_log: Arc<GitLog>,
/// Initialise a repository.
pub git_init: Arc<GitInit>,
/// Return the commit graph for all local branches.
pub git_graph: Arc<GitGraph>,
}
impl AppState {
/// **Composition root.** Builds all adapters and use cases.
///
/// `app_data_dir` is the machine-local IDE data directory (ARCHITECTURE
/// §9.2), resolved by the caller via the Tauri path API and injected here so
/// the stores never touch Tauri themselves (Dependency Inversion).
///
/// This is the only function that constructs concrete adapters; every other
/// layer depends on ports. Adapters added in later lots (PTY, git, remote)
/// are wired in here.
#[must_use]
pub fn build(app_data_dir: PathBuf) -> Self {
// --- Concrete adapters (driven adapters) ---
let event_bus = Arc::new(TokioBroadcastEventBus::new());
let clock = Arc::new(SystemClock::new());
let ids = Arc::new(UuidGenerator::new());
let fs = Arc::new(LocalFileSystem::new());
let store = Arc::new(FsProjectStore::new(
Arc::clone(&fs) as Arc<dyn FileSystem>,
app_data_dir.to_string_lossy().into_owned(),
));
// Port-typed handles for injection.
let fs_port = Arc::clone(&fs) as Arc<dyn FileSystem>;
let store_port = Arc::clone(&store) as Arc<dyn ProjectStore>;
let events_port = Arc::clone(&event_bus) as Arc<dyn EventBus>;
// --- Use cases (ports injected as Arc<dyn Port>) ---
let health = Arc::new(HealthUseCase::new(
Arc::clone(&clock) as Arc<dyn Clock>,
Arc::clone(&ids) as Arc<dyn IdGenerator>,
Arc::clone(&events_port),
));
let create_project = Arc::new(CreateProject::new(
Arc::clone(&store_port),
Arc::clone(&fs_port),
Arc::clone(&ids) as Arc<dyn IdGenerator>,
Arc::clone(&clock) as Arc<dyn Clock>,
Arc::clone(&events_port),
));
let open_project = Arc::new(OpenProject::new(
Arc::clone(&store_port),
Arc::clone(&fs_port),
));
let close_project = Arc::new(CloseProject::new(Arc::clone(&store_port)));
let close_tab = Arc::new(CloseTab::new(Arc::clone(&store_port)));
let list_projects = Arc::new(ListProjects::new(Arc::clone(&store_port)));
// --- PTY adapter + terminal use cases (L3) ---
let pty = Arc::new(PortablePtyAdapter::new());
let pty_port = Arc::clone(&pty) as Arc<dyn PtyPort>;
let terminal_sessions = Arc::new(TerminalSessions::new());
let open_terminal = Arc::new(OpenTerminal::new(
Arc::clone(&pty_port),
Arc::clone(&terminal_sessions),
Arc::clone(&events_port),
));
let write_terminal = Arc::new(WriteToTerminal::new(
Arc::clone(&pty_port),
Arc::clone(&terminal_sessions),
));
let resize_terminal = Arc::new(ResizeTerminal::new(
Arc::clone(&pty_port),
Arc::clone(&terminal_sessions),
));
let close_terminal = Arc::new(CloseTerminal::new(
Arc::clone(&pty_port),
Arc::clone(&terminal_sessions),
));
// --- Layout use cases (L4 + #4) ---
let load_layout = Arc::new(LoadLayout::new(
Arc::clone(&store_port),
Arc::clone(&fs_port),
));
let mutate_layout = Arc::new(MutateLayout::new(
Arc::clone(&store_port),
Arc::clone(&fs_port),
Arc::clone(&events_port),
));
let list_layouts = Arc::new(ListLayouts::new(
Arc::clone(&store_port),
Arc::clone(&fs_port),
));
let create_layout = Arc::new(CreateLayout::new(
Arc::clone(&store_port),
Arc::clone(&fs_port),
Arc::clone(&ids) as Arc<dyn IdGenerator>,
Arc::clone(&events_port),
));
let rename_layout = Arc::new(RenameLayout::new(
Arc::clone(&store_port),
Arc::clone(&fs_port),
Arc::clone(&events_port),
));
let delete_layout = Arc::new(DeleteLayout::new(
Arc::clone(&store_port),
Arc::clone(&fs_port),
Arc::clone(&events_port),
));
let set_active_layout = Arc::new(SetActiveLayout::new(
Arc::clone(&store_port),
Arc::clone(&fs_port),
Arc::clone(&events_port),
));
// --- Profiles & AI runtime (L5) ---
// One generic, profile-driven runtime adapter (Open/Closed): it holds the
// process spawner used for detection. The profile store persists
// `profiles.json` in the same machine-local app-data dir as the project
// registry.
let spawner = Arc::new(LocalProcessSpawner::new());
let spawner_port = Arc::clone(&spawner) as Arc<dyn ProcessSpawner>;
let runtime = Arc::new(CliAgentRuntime::new(Arc::clone(&spawner_port)));
let runtime_port = Arc::clone(&runtime) as Arc<dyn AgentRuntime>;
let profile_store = Arc::new(FsProfileStore::new(
Arc::clone(&fs_port),
app_data_dir.to_string_lossy().into_owned(),
));
let profile_store_port = Arc::clone(&profile_store) as Arc<dyn ProfileStore>;
let detect_profiles = Arc::new(DetectProfiles::new(Arc::clone(&runtime_port)));
let list_profiles = Arc::new(ListProfiles::new(Arc::clone(&profile_store_port)));
let save_profile = Arc::new(SaveProfile::new(Arc::clone(&profile_store_port)));
let delete_profile = Arc::new(DeleteProfile::new(Arc::clone(&profile_store_port)));
let configure_profiles = Arc::new(ConfigureProfiles::new(Arc::clone(&profile_store_port)));
let reference_profiles = Arc::new(ReferenceProfiles::new());
let first_run_state = Arc::new(FirstRunState::new(Arc::clone(&profile_store_port)));
let pty_bridge = Arc::new(PtyBridge::new());
// --- Agent context store + use cases (L6) ---
let contexts = Arc::new(IdeaiContextStore::new(Arc::clone(&fs_port)));
let contexts_port = Arc::clone(&contexts) as Arc<dyn AgentContextStore>;
let create_agent = Arc::new(CreateAgentFromScratch::new(
Arc::clone(&contexts_port),
Arc::clone(&ids) as Arc<dyn IdGenerator>,
Arc::clone(&events_port),
));
let list_agents = Arc::new(ListAgents::new(Arc::clone(&contexts_port)));
let read_agent_context = Arc::new(ReadAgentContext::new(Arc::clone(&contexts_port)));
let update_agent_context = Arc::new(UpdateAgentContext::new(Arc::clone(&contexts_port)));
let delete_agent = Arc::new(DeleteAgent::new(
Arc::clone(&contexts_port),
Arc::clone(&events_port),
));
// LaunchAgent shares the SAME pty_port and terminal_sessions as the terminal
// use cases — indispensable for the PtyBridge to work correctly.
let launch_agent = Arc::new(LaunchAgent::new(
Arc::clone(&contexts_port),
Arc::clone(&profile_store_port),
Arc::clone(&runtime_port),
Arc::clone(&fs_port),
Arc::clone(&pty_port),
Arc::clone(&terminal_sessions),
Arc::clone(&events_port),
));
let project_store = Arc::clone(&store_port);
// --- Template store + use cases (L7) ---
let template_store = Arc::new(FsTemplateStore::new(
Arc::clone(&fs_port),
app_data_dir.to_string_lossy().into_owned(),
));
let template_store_port = Arc::clone(&template_store) as Arc<dyn TemplateStore>;
let create_template = Arc::new(CreateTemplate::new(
Arc::clone(&template_store_port),
Arc::clone(&ids) as Arc<dyn IdGenerator>,
));
let update_template = Arc::new(UpdateTemplate::new(
Arc::clone(&template_store_port),
Arc::clone(&events_port),
));
let list_templates = Arc::new(ListTemplates::new(Arc::clone(&template_store_port)));
let delete_template = Arc::new(DeleteTemplate::new(Arc::clone(&template_store_port)));
let create_agent_from_template = Arc::new(CreateAgentFromTemplate::new(
Arc::clone(&template_store_port),
Arc::clone(&contexts_port),
Arc::clone(&ids) as Arc<dyn IdGenerator>,
Arc::clone(&events_port),
));
let detect_agent_drift = Arc::new(DetectAgentDrift::new(
Arc::clone(&template_store_port),
Arc::clone(&contexts_port),
Arc::clone(&events_port),
));
let sync_agent_with_template = Arc::new(SyncAgentWithTemplate::new(
Arc::clone(&template_store_port),
Arc::clone(&contexts_port),
Arc::clone(&events_port),
));
// --- Git adapter + use cases (L8) ---
let git = Arc::new(Git2Repository::new());
let git_port = Arc::clone(&git) as Arc<dyn GitPort>;
let git_status = Arc::new(GitStatus::new(Arc::clone(&git_port)));
let git_stage = Arc::new(GitStage::new(Arc::clone(&git_port)));
let git_unstage = Arc::new(GitUnstage::new(Arc::clone(&git_port)));
let git_commit = Arc::new(GitCommit::new(Arc::clone(&git_port), Arc::clone(&events_port)));
let git_branches = Arc::new(GitBranches::new(Arc::clone(&git_port)));
let git_checkout = Arc::new(GitCheckout::new(
Arc::clone(&git_port),
Arc::clone(&events_port),
));
let git_log = Arc::new(GitLog::new(Arc::clone(&git_port)));
let git_init = Arc::new(GitInit::new(Arc::clone(&git_port), Arc::clone(&events_port)));
let git_graph = Arc::new(GitGraph::new(Arc::clone(&git_port)));
// --- Windows (L10) ---
let move_tab = Arc::new(MoveTabToNewWindow::new(
Arc::clone(&store_port),
Arc::clone(&ids) as Arc<dyn IdGenerator>,
));
Self {
health,
create_project,
open_project,
close_project,
close_tab,
list_projects,
open_terminal,
write_terminal,
resize_terminal,
close_terminal,
load_layout,
mutate_layout,
list_layouts,
create_layout,
rename_layout,
delete_layout,
set_active_layout,
detect_profiles,
list_profiles,
save_profile,
delete_profile,
configure_profiles,
reference_profiles,
first_run_state,
pty_port,
terminal_sessions,
event_bus,
pty_bridge,
create_agent,
list_agents,
read_agent_context,
update_agent_context,
delete_agent,
launch_agent,
project_store,
create_template,
update_template,
list_templates,
delete_template,
create_agent_from_template,
detect_agent_drift,
sync_agent_with_template,
git_status,
git_stage,
git_unstage,
git_commit,
git_branches,
git_checkout,
git_log,
git_init,
git_graph,
move_tab,
}
}
}

View File

@ -0,0 +1,29 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "IdeA",
"version": "0.1.0",
"identifier": "app.idea.ide",
"build": {
"frontendDist": "../../frontend/dist",
"devUrl": "http://localhost:5173",
"beforeDevCommand": "npm --prefix ../frontend run dev"
},
"app": {
"windows": [
{
"title": "IdeA",
"width": 1280,
"height": 800,
"resizable": true
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": ["appimage", "nsis"],
"icon": ["icons/icon.png"]
}
}

View File

@ -0,0 +1,326 @@
//! L1 tests for the IPC DTO (de)serialisation contract: camelCase on the wire,
//! stable `ErrorDto.code`, and the `DomainEvent -> DomainEventDto` mapping with
//! its tagged, camelCase JSON shape.
use app_tauri_lib::dto::{
parse_node_id, parse_session_id, ErrorDto, HealthRequestDto, HealthResponseDto, LayoutDto,
LayoutOperationDto, OpenTerminalRequestDto, ResizeTerminalRequestDto, TerminalClosedDto,
WriteTerminalRequestDto,
};
use application::{CloseTerminalOutput, LayoutOperation, LoadLayoutOutput, OpenTerminalInput};
use domain::{Direction, LayoutNode, LayoutTree, LeafCell, NodeId};
use app_tauri_lib::events::{DomainEventDto, DOMAIN_EVENT};
use application::{AppError, HealthInput};
use domain::events::DomainEvent;
use domain::ids::{AgentId, SessionId};
use domain::ProjectId;
use domain::TemplateVersion;
use domain::TemplateId;
use serde_json::json;
use uuid::Uuid;
#[test]
fn health_response_serialises_camel_case() {
let dto = HealthResponseDto {
version: "1.2.3".into(),
alive: true,
time_millis: 42,
correlation_id: "abc".into(),
note: Some("hi".into()),
};
let v = serde_json::to_value(&dto).unwrap();
assert_eq!(
v,
json!({
"version": "1.2.3",
"alive": true,
"timeMillis": 42,
"correlationId": "abc",
"note": "hi",
})
);
}
#[test]
fn health_request_deserialises_camel_case_and_defaults() {
let dto: HealthRequestDto = serde_json::from_value(json!({ "note": "x" })).unwrap();
assert_eq!(HealthInput::from(dto).note.as_deref(), Some("x"));
// Missing note defaults to None.
let empty: HealthRequestDto = serde_json::from_value(json!({})).unwrap();
assert_eq!(HealthInput::from(empty).note, None);
}
#[test]
fn error_dto_carries_stable_code_and_message() {
let dto = ErrorDto::from(AppError::NotFound("project".into()));
assert_eq!(dto.code, "NOT_FOUND");
assert!(dto.message.contains("project"));
let v = serde_json::to_value(&dto).unwrap();
assert_eq!(v["code"], "NOT_FOUND");
assert!(v.get("message").is_some());
}
#[test]
fn domain_event_relay_name_is_frozen() {
assert_eq!(DOMAIN_EVENT, "domain://event");
}
#[test]
fn domain_event_dto_is_tagged_and_camel_case() {
let pid = ProjectId::from_uuid(Uuid::nil());
let dto = DomainEventDto::from(&DomainEvent::ProjectCreated { project_id: pid });
let v = serde_json::to_value(&dto).unwrap();
assert_eq!(
v,
json!({ "type": "projectCreated", "projectId": pid.to_string() })
);
}
#[test]
fn domain_event_dto_maps_agent_launched() {
let aid = AgentId::from_uuid(Uuid::nil());
let sid = SessionId::from_uuid(Uuid::nil());
let dto = DomainEventDto::from(&DomainEvent::AgentLaunched {
agent_id: aid,
session_id: sid,
});
let v = serde_json::to_value(&dto).unwrap();
assert_eq!(v["type"], "agentLaunched");
assert_eq!(v["agentId"], aid.to_string());
assert_eq!(v["sessionId"], sid.to_string());
}
#[test]
fn domain_event_dto_maps_template_updated_version() {
let tid = TemplateId::from_uuid(Uuid::nil());
let dto = DomainEventDto::from(&DomainEvent::TemplateUpdated {
template_id: tid,
version: TemplateVersion::INITIAL,
});
let v = serde_json::to_value(&dto).unwrap();
assert_eq!(v["type"], "templateUpdated");
assert_eq!(v["templateId"], tid.to_string());
assert!(v["version"].is_u64());
}
#[test]
fn domain_event_dto_maps_pty_output_bytes() {
let sid = SessionId::from_uuid(Uuid::nil());
let dto = DomainEventDto::from(&DomainEvent::PtyOutput {
session_id: sid,
bytes: vec![1, 2, 3],
});
let v = serde_json::to_value(&dto).unwrap();
assert_eq!(v["type"], "ptyOutput");
assert_eq!(v["bytes"], json!([1, 2, 3]));
}
// ---------------------------------------------------------------------------
// Terminal DTOs (L3)
// ---------------------------------------------------------------------------
#[test]
fn open_terminal_request_deserialises_camel_case_with_defaults() {
// Minimal payload: command/args omitted → None / empty.
let dto: OpenTerminalRequestDto =
serde_json::from_value(json!({ "cwd": "/p", "rows": 24, "cols": 80 })).unwrap();
let input = OpenTerminalInput::from(dto);
assert_eq!(input.cwd, "/p");
assert_eq!(input.rows, 24);
assert_eq!(input.cols, 80);
assert_eq!(input.command, None);
assert!(input.args.is_empty());
assert_eq!(input.node_id, None);
// Full payload with explicit command + args.
let full: OpenTerminalRequestDto = serde_json::from_value(json!({
"cwd": "/q", "rows": 10, "cols": 20,
"command": "/bin/zsh", "args": ["-l"],
}))
.unwrap();
let input = OpenTerminalInput::from(full);
assert_eq!(input.command.as_deref(), Some("/bin/zsh"));
assert_eq!(input.args, vec!["-l".to_owned()]);
}
#[test]
fn write_terminal_request_deserialises_session_id_and_data() {
let sid = SessionId::from_uuid(Uuid::nil());
let dto: WriteTerminalRequestDto = serde_json::from_value(json!({
"sessionId": sid.to_string(),
"data": [104, 105],
}))
.unwrap();
let input = dto.into_input().expect("valid session id");
assert_eq!(input.session_id, sid);
assert_eq!(input.data, vec![104u8, 105]);
}
#[test]
fn write_terminal_request_rejects_bad_session_id() {
let dto: WriteTerminalRequestDto =
serde_json::from_value(json!({ "sessionId": "not-a-uuid", "data": [] })).unwrap();
let err = dto.into_input().expect_err("malformed id rejected");
assert_eq!(err.code, "INVALID");
}
#[test]
fn resize_terminal_request_deserialises_camel_case() {
let sid = SessionId::from_uuid(Uuid::nil());
let dto: ResizeTerminalRequestDto = serde_json::from_value(json!({
"sessionId": sid.to_string(), "rows": 40, "cols": 120,
}))
.unwrap();
let input = dto.into_input().expect("valid");
assert_eq!(input.session_id, sid);
assert_eq!(input.rows, 40);
assert_eq!(input.cols, 120);
}
#[test]
fn terminal_closed_dto_serialises_code_camel_case() {
let dto = TerminalClosedDto::from(CloseTerminalOutput { code: Some(0) });
let v = serde_json::to_value(&dto).unwrap();
assert_eq!(v, json!({ "code": 0 }));
// Signalled (None) round-trips as null.
let none = TerminalClosedDto::from(CloseTerminalOutput { code: None });
assert_eq!(serde_json::to_value(&none).unwrap(), json!({ "code": null }));
}
#[test]
fn parse_session_id_accepts_uuid_and_rejects_garbage() {
let sid = SessionId::from_uuid(Uuid::nil());
assert_eq!(parse_session_id(&sid.to_string()).unwrap(), sid);
assert_eq!(parse_session_id("nope").unwrap_err().code, "INVALID");
}
// ---------------------------------------------------------------------------
// Layout (L4)
// ---------------------------------------------------------------------------
fn nid(n: u128) -> NodeId {
NodeId::from_uuid(Uuid::from_u128(n))
}
#[test]
fn layout_dto_serialises_camelcase_tagged_tree() {
// A single-leaf tree → transparent over LayoutTree, tagged `{type,node}`.
let tree = LayoutTree::single(LeafCell {
id: nid(1),
session: None,
agent: None,
});
let dto = LayoutDto::from(LoadLayoutOutput {
layout_id: domain::LayoutId::new_random(),
layout: tree,
});
let v = serde_json::to_value(&dto).unwrap();
assert_eq!(v["root"]["type"], "leaf", "enum tagged on `type`");
assert_eq!(v["root"]["node"]["id"], nid(1).to_string());
// Empty session is skipped (skip_serializing_if).
assert!(v["root"]["node"].get("session").is_none());
}
#[test]
fn layout_operation_dto_split_deserialises_camelcase() {
let json = json!({
"type": "split",
"target": nid(1).to_string(),
"direction": "row",
"newLeaf": nid(2).to_string(),
"container": nid(9).to_string(),
});
let dto: LayoutOperationDto = serde_json::from_value(json).unwrap();
match dto.into_operation().unwrap() {
LayoutOperation::Split {
target,
direction,
new_leaf,
container,
} => {
assert_eq!(target, nid(1));
assert_eq!(direction, Direction::Row);
assert_eq!(new_leaf, nid(2));
assert_eq!(container, nid(9));
}
other => panic!("expected Split, got {other:?}"),
}
}
#[test]
fn layout_operation_dto_set_session_accepts_null_session() {
// `session: null` → detach (None).
let json = json!({
"type": "setSession",
"target": nid(1).to_string(),
"session": null,
});
let dto: LayoutOperationDto = serde_json::from_value(json).unwrap();
match dto.into_operation().unwrap() {
LayoutOperation::SetSession { target, session } => {
assert_eq!(target, nid(1));
assert!(session.is_none());
}
other => panic!("expected SetSession, got {other:?}"),
}
}
#[test]
fn layout_operation_dto_resize_carries_weights() {
let json = json!({
"type": "resize",
"container": nid(9).to_string(),
"weights": [3.0, 1.0],
});
let dto: LayoutOperationDto = serde_json::from_value(json).unwrap();
match dto.into_operation().unwrap() {
LayoutOperation::Resize { container, weights } => {
assert_eq!(container, nid(9));
assert_eq!(weights, vec![3.0, 1.0]);
}
other => panic!("expected Resize, got {other:?}"),
}
}
#[test]
fn layout_operation_dto_rejects_malformed_uuid_as_invalid() {
let json = json!({
"type": "merge",
"container": "not-a-uuid",
"keepIndex": 0,
});
let dto: LayoutOperationDto = serde_json::from_value(json).unwrap();
let err = dto.into_operation().expect_err("malformed uuid rejected");
assert_eq!(err.code, "INVALID", "got {err:?}");
}
#[test]
fn parse_node_id_accepts_uuid_and_rejects_garbage() {
assert_eq!(parse_node_id(&nid(7).to_string()).unwrap(), nid(7));
assert_eq!(parse_node_id("garbage").unwrap_err().code, "INVALID");
}
#[test]
fn layout_dto_round_trips_a_split_tree_shape() {
// Build a split tree, serialise via the DTO, and confirm the tagged shape
// re-parses into an equivalent LayoutTree.
let tree = LayoutTree::single(LeafCell {
id: nid(1),
session: None,
agent: None,
})
.split(nid(1), Direction::Column, LeafCell { id: nid(2), session: None, agent: None }, nid(9))
.unwrap();
let dto = LayoutDto::from(LoadLayoutOutput {
layout_id: domain::LayoutId::new_random(),
layout: tree.clone(),
});
let v = serde_json::to_value(&dto).unwrap();
let back: LayoutTree = serde_json::from_value(v).unwrap();
assert_eq!(back, tree, "DTO serialisation round-trips the tree");
assert!(matches!(back.root, LayoutNode::Split(_)));
}

View File

@ -0,0 +1,184 @@
//! L6 tests for the agent DTO (de)serialisation contract: camelCase on the
//! wire, embedded [`Agent`] shape preserved, `parse_agent_id` error behaviour,
//! and `From<LaunchAgentOutput>` for [`TerminalSessionDto`].
use app_tauri_lib::dto::{
parse_agent_id, AgentDto, AgentListDto, CreateAgentRequestDto, LaunchAgentRequestDto,
TerminalSessionDto, UpdateAgentContextRequestDto,
};
use application::{CreateAgentOutput, LaunchAgentOutput, ListAgentsOutput};
use domain::ids::{AgentId, NodeId, ProfileId, SessionId};
use domain::terminal::{PtySize, SessionKind, SessionStatus, TerminalSession};
use domain::{Agent, AgentOrigin, ProjectPath};
use serde_json::json;
use uuid::Uuid;
/// Helper: build a minimal validated [`Agent`].
fn make_agent(agent_uuid: u128, profile_uuid: u128) -> Agent {
Agent::new(
AgentId::from_uuid(Uuid::from_u128(agent_uuid)),
"My Agent",
"agents/my-agent.md",
ProfileId::from_uuid(Uuid::from_u128(profile_uuid)),
AgentOrigin::Scratch,
false,
)
.expect("valid agent")
}
// ---------------------------------------------------------------------------
// AgentDto / AgentListDto serialisation
// ---------------------------------------------------------------------------
#[test]
fn agent_dto_serialises_camelcase() {
let agent = make_agent(1, 2);
let dto = AgentDto(agent.clone());
let v = serde_json::to_value(&dto).unwrap();
assert_eq!(v["id"], agent.id.to_string());
assert_eq!(v["name"], "My Agent");
assert_eq!(v["contextPath"], "agents/my-agent.md", "camelCase key");
assert_eq!(v["profileId"], ProfileId::from_uuid(Uuid::from_u128(2)).to_string());
assert_eq!(v["synchronized"], false);
// origin: tagged `{ "type": "scratch" }`
assert_eq!(v["origin"]["type"], "scratch");
// no snake_case leak
assert!(v.get("context_path").is_none());
assert!(v.get("profile_id").is_none());
}
#[test]
fn agent_list_dto_is_transparent_array() {
let out = ListAgentsOutput {
agents: vec![make_agent(1, 2), make_agent(3, 4)],
};
let dto = AgentListDto::from(out);
let v = serde_json::to_value(&dto).unwrap();
let arr = v.as_array().expect("transparent array");
assert_eq!(arr.len(), 2);
assert_eq!(arr[0]["name"], "My Agent");
}
#[test]
fn create_agent_output_maps_to_agent_dto() {
let agent = make_agent(5, 6);
let out = CreateAgentOutput { agent: agent.clone() };
let dto = AgentDto::from(out);
assert_eq!(dto.0.id, agent.id);
}
// ---------------------------------------------------------------------------
// Request DTO deserialisation
// ---------------------------------------------------------------------------
#[test]
fn create_agent_request_deserialises_camelcase() {
let project_id = Uuid::from_u128(10).to_string();
let profile_id = Uuid::from_u128(20).to_string();
let raw = json!({
"projectId": project_id,
"name": "Backend Dev",
"profileId": profile_id,
"initialContent": "# Hello"
});
let dto: CreateAgentRequestDto = serde_json::from_value(raw).unwrap();
assert_eq!(dto.project_id, project_id);
assert_eq!(dto.name, "Backend Dev");
assert_eq!(dto.profile_id, profile_id);
assert_eq!(dto.initial_content.as_deref(), Some("# Hello"));
}
#[test]
fn create_agent_request_initial_content_defaults_to_none() {
let raw = json!({
"projectId": Uuid::nil().to_string(),
"name": "Agent",
"profileId": Uuid::nil().to_string()
});
let dto: CreateAgentRequestDto = serde_json::from_value(raw).unwrap();
assert!(dto.initial_content.is_none());
}
#[test]
fn update_agent_context_request_deserialises_camelcase() {
let raw = json!({
"projectId": Uuid::nil().to_string(),
"agentId": Uuid::nil().to_string(),
"content": "# Updated"
});
let dto: UpdateAgentContextRequestDto = serde_json::from_value(raw).unwrap();
assert_eq!(dto.content, "# Updated");
}
#[test]
fn launch_agent_request_deserialises_camelcase() {
let project_id = Uuid::from_u128(1).to_string();
let agent_id = Uuid::from_u128(2).to_string();
let raw = json!({
"projectId": project_id,
"agentId": agent_id,
"rows": 24,
"cols": 80
});
let dto: LaunchAgentRequestDto = serde_json::from_value(raw).unwrap();
assert_eq!(dto.rows, 24);
assert_eq!(dto.cols, 80);
assert_eq!(dto.project_id, project_id);
assert_eq!(dto.agent_id, agent_id);
}
// ---------------------------------------------------------------------------
// parse_agent_id
// ---------------------------------------------------------------------------
#[test]
fn parse_agent_id_accepts_uuid() {
let id = Uuid::from_u128(99);
assert_eq!(
parse_agent_id(&id.to_string()).unwrap(),
AgentId::from_uuid(id)
);
}
#[test]
fn parse_agent_id_rejects_garbage() {
let err = parse_agent_id("not-a-uuid").expect_err("garbage rejected");
assert_eq!(err.code, "INVALID");
}
// ---------------------------------------------------------------------------
// From<LaunchAgentOutput> for TerminalSessionDto
// ---------------------------------------------------------------------------
#[test]
fn launch_agent_output_maps_to_terminal_session_dto() {
let session_id = SessionId::from_uuid(Uuid::from_u128(7));
let node_id = NodeId::from_uuid(Uuid::from_u128(8));
let agent_id = AgentId::from_uuid(Uuid::from_u128(9));
let cwd = ProjectPath::new("/tmp/project".to_owned()).expect("valid path");
let size = PtySize::new(24, 80).unwrap();
let mut session = TerminalSession::starting(
session_id,
node_id,
cwd,
SessionKind::Agent { agent_id },
size,
);
session.status = SessionStatus::Running;
let out = LaunchAgentOutput { session };
let dto = TerminalSessionDto::from(out);
assert_eq!(dto.session_id, session_id.to_string());
assert_eq!(dto.cwd, "/tmp/project");
assert_eq!(dto.rows, 24);
assert_eq!(dto.cols, 80);
// Also verify camelCase serialisation.
let v = serde_json::to_value(&dto).unwrap();
assert_eq!(v["sessionId"], session_id.to_string());
assert_eq!(v["cwd"], "/tmp/project");
assert!(v.get("session_id").is_none(), "no snake_case leak");
}

View File

@ -0,0 +1,262 @@
//! L8 tests for the Git DTO (de)serialisation contract: camelCase on the
//! wire, transparent list shapes, `From<…Output>` conversions, and request
//! DTO deserialisation.
use app_tauri_lib::dto::{
GitBranchesDto, GitCheckoutRequestDto, GitCommitDto, GitCommitListDto, GitCommitRequestDto,
GitFileStatusDto, GitStageRequestDto, GitStatusListDto, GraphCommitDto, GraphCommitListDto,
};
use application::{GitBranchesOutput, GitCommitOutput, GitGraphOutput, GitLogOutput, GitStatusOutput};
use domain::ports::{GitCommitInfo, GitFileStatus, GraphCommit};
use serde_json::json;
use uuid::Uuid;
// ---------------------------------------------------------------------------
// GitFileStatusDto serialisation
// ---------------------------------------------------------------------------
#[test]
fn git_file_status_dto_serialises_camelcase() {
let dto = GitFileStatusDto {
path: "src/main.rs".to_owned(),
staged: true,
};
let v = serde_json::to_value(&dto).unwrap();
assert_eq!(v["path"], "src/main.rs");
assert_eq!(v["staged"], true);
// no snake_case leak — both fields are single words so no renaming needed,
// but verify no extra/unexpected keys.
assert!(v.get("path").is_some());
assert!(v.get("staged").is_some());
}
#[test]
fn git_file_status_from_domain_type() {
let domain = GitFileStatus {
path: "README.md".to_owned(),
staged: false,
};
let dto = GitFileStatusDto::from(domain);
assert_eq!(dto.path, "README.md");
assert!(!dto.staged);
}
// ---------------------------------------------------------------------------
// GitStatusListDto — transparent array
// ---------------------------------------------------------------------------
#[test]
fn git_status_list_dto_is_transparent_array() {
let out = GitStatusOutput {
entries: vec![
GitFileStatus {
path: "a.rs".to_owned(),
staged: true,
},
GitFileStatus {
path: "b.rs".to_owned(),
staged: false,
},
],
};
let dto = GitStatusListDto::from(out);
let v = serde_json::to_value(&dto).unwrap();
let arr = v.as_array().expect("transparent array");
assert_eq!(arr.len(), 2);
assert_eq!(arr[0]["path"], "a.rs");
assert_eq!(arr[0]["staged"], true);
assert_eq!(arr[1]["path"], "b.rs");
assert_eq!(arr[1]["staged"], false);
}
// ---------------------------------------------------------------------------
// GitCommitDto serialisation & From impls
// ---------------------------------------------------------------------------
#[test]
fn git_commit_dto_serialises_camelcase() {
let dto = GitCommitDto {
hash: "abc123".to_owned(),
summary: "feat: add git support".to_owned(),
};
let v = serde_json::to_value(&dto).unwrap();
assert_eq!(v["hash"], "abc123");
assert_eq!(v["summary"], "feat: add git support");
}
#[test]
fn git_commit_dto_from_commit_info() {
let info = GitCommitInfo {
hash: "deadbeef".to_owned(),
summary: "fix: something".to_owned(),
};
let dto = GitCommitDto::from(info);
assert_eq!(dto.hash, "deadbeef");
assert_eq!(dto.summary, "fix: something");
}
#[test]
fn git_commit_dto_from_commit_output() {
let out = GitCommitOutput {
commit: GitCommitInfo {
hash: "cafebabe".to_owned(),
summary: "chore: cleanup".to_owned(),
},
};
let dto = GitCommitDto::from(out);
assert_eq!(dto.hash, "cafebabe");
assert_eq!(dto.summary, "chore: cleanup");
}
// ---------------------------------------------------------------------------
// GitCommitListDto — transparent array
// ---------------------------------------------------------------------------
#[test]
fn git_commit_list_dto_is_transparent_array() {
let out = GitLogOutput {
commits: vec![
GitCommitInfo {
hash: "aaa".to_owned(),
summary: "first".to_owned(),
},
GitCommitInfo {
hash: "bbb".to_owned(),
summary: "second".to_owned(),
},
],
};
let dto = GitCommitListDto::from(out);
let v = serde_json::to_value(&dto).unwrap();
let arr = v.as_array().expect("transparent array");
assert_eq!(arr.len(), 2);
assert_eq!(arr[0]["hash"], "aaa");
assert_eq!(arr[0]["summary"], "first");
}
// ---------------------------------------------------------------------------
// GitBranchesDto serialisation (incl. current: null)
// ---------------------------------------------------------------------------
#[test]
fn git_branches_dto_serialises_with_current() {
let out = GitBranchesOutput {
branches: vec!["main".to_owned(), "feature/x".to_owned()],
current: Some("main".to_owned()),
};
let dto = GitBranchesDto::from(out);
let v = serde_json::to_value(&dto).unwrap();
assert_eq!(v["branches"][0], "main");
assert_eq!(v["branches"][1], "feature/x");
assert_eq!(v["current"], "main");
}
#[test]
fn git_branches_dto_serialises_null_current() {
let out = GitBranchesOutput {
branches: vec![],
current: None,
};
let dto = GitBranchesDto::from(out);
let v = serde_json::to_value(&dto).unwrap();
assert!(v["current"].is_null(), "current must be null when None");
}
// ---------------------------------------------------------------------------
// GraphCommitDto serialisation & From impls
// ---------------------------------------------------------------------------
#[test]
fn graph_commit_dto_serialises_camelcase() {
let commit = GraphCommit {
hash: "abc123".to_owned(),
summary: "feat: graph".to_owned(),
parents: vec!["deadbeef".to_owned()],
refs: vec!["main".to_owned(), "tag: v1.0".to_owned()],
author: "Alice".to_owned(),
timestamp: 1_700_000_000,
};
let dto = GraphCommitDto::from(commit);
let v = serde_json::to_value(&dto).unwrap();
assert_eq!(v["hash"], "abc123");
assert_eq!(v["summary"], "feat: graph");
assert_eq!(v["parents"][0], "deadbeef");
assert_eq!(v["refs"][0], "main");
assert_eq!(v["refs"][1], "tag: v1.0");
assert_eq!(v["author"], "Alice");
assert_eq!(v["timestamp"], 1_700_000_000_i64);
// No snake_case leaks for multi-word fields — none here but verify shape.
assert!(v.get("hash").is_some());
}
#[test]
fn graph_commit_list_dto_is_transparent_array() {
let out = GitGraphOutput {
commits: vec![
GraphCommit {
hash: "aaa".to_owned(),
summary: "first".to_owned(),
parents: vec![],
refs: vec!["main".to_owned()],
author: "Bob".to_owned(),
timestamp: 1000,
},
GraphCommit {
hash: "bbb".to_owned(),
summary: "second".to_owned(),
parents: vec!["aaa".to_owned()],
refs: vec![],
author: "Bob".to_owned(),
timestamp: 999,
},
],
};
let dto = GraphCommitListDto::from(out);
let v = serde_json::to_value(&dto).unwrap();
let arr = v.as_array().expect("transparent array");
assert_eq!(arr.len(), 2);
assert_eq!(arr[0]["hash"], "aaa");
assert_eq!(arr[0]["refs"][0], "main");
assert_eq!(arr[1]["hash"], "bbb");
assert!(arr[1]["parents"][0] == "aaa");
}
// ---------------------------------------------------------------------------
// Request DTO deserialisation (camelCase)
// ---------------------------------------------------------------------------
#[test]
fn git_stage_request_deserialises_camelcase() {
let project_id = Uuid::from_u128(1).to_string();
let raw = json!({
"projectId": project_id,
"path": "src/lib.rs"
});
let dto: GitStageRequestDto = serde_json::from_value(raw).unwrap();
assert_eq!(dto.project_id, project_id);
assert_eq!(dto.path, "src/lib.rs");
}
#[test]
fn git_commit_request_deserialises_camelcase() {
let project_id = Uuid::from_u128(2).to_string();
let raw = json!({
"projectId": project_id,
"message": "feat: initial commit"
});
let dto: GitCommitRequestDto = serde_json::from_value(raw).unwrap();
assert_eq!(dto.project_id, project_id);
assert_eq!(dto.message, "feat: initial commit");
}
#[test]
fn git_checkout_request_deserialises_camelcase() {
let project_id = Uuid::from_u128(3).to_string();
let raw = json!({
"projectId": project_id,
"branch": "feature/awesome"
});
let dto: GitCheckoutRequestDto = serde_json::from_value(raw).unwrap();
assert_eq!(dto.project_id, project_id);
assert_eq!(dto.branch, "feature/awesome");
}

View File

@ -0,0 +1,247 @@
//! Tests for the layout-management (#4) and per-cell-agent (#3) DTOs:
//! camelCase wire shape, `parse_layout_id` error behaviour, `ListLayoutsDto` /
//! `CreateLayoutResultDto` / `DeleteLayoutResultDto` mappings, and
//! `setCellAgent` operation deserialisation.
use app_tauri_lib::dto::{
parse_layout_id, CreateLayoutRequestDto, CreateLayoutResultDto, DeleteLayoutRequestDto,
DeleteLayoutResultDto, LayoutInfoDto, LayoutOperationDto, ListLayoutsDto, RenameLayoutRequestDto,
SetActiveLayoutRequestDto,
};
use application::{
CreateLayoutOutput, DeleteLayoutOutput, LayoutInfo, LayoutKind, ListLayoutsOutput,
};
use domain::ids::{AgentId, LayoutId, NodeId};
use serde_json::json;
use uuid::Uuid;
fn lid(n: u128) -> LayoutId {
LayoutId::from_uuid(Uuid::from_u128(n))
}
fn nid(n: u128) -> NodeId {
NodeId::from_uuid(Uuid::from_u128(n))
}
fn aid(n: u128) -> AgentId {
AgentId::from_uuid(Uuid::from_u128(n))
}
// ---------------------------------------------------------------------------
// LayoutInfoDto
// ---------------------------------------------------------------------------
#[test]
fn layout_info_dto_serialises_camelcase() {
let info = LayoutInfo {
id: lid(1),
name: "Default".to_owned(),
kind: LayoutKind::Terminal,
};
let dto = LayoutInfoDto::from(info);
let v = serde_json::to_value(&dto).unwrap();
assert_eq!(v["id"], lid(1).to_string());
assert_eq!(v["name"], "Default");
assert_eq!(v["kind"], "terminal");
}
#[test]
fn layout_info_dto_git_graph_kind() {
let info = LayoutInfo {
id: lid(2),
name: "Graph".to_owned(),
kind: LayoutKind::GitGraph,
};
let dto = LayoutInfoDto::from(info);
let v = serde_json::to_value(&dto).unwrap();
assert_eq!(v["kind"], "gitGraph");
}
// ---------------------------------------------------------------------------
// ListLayoutsDto
// ---------------------------------------------------------------------------
#[test]
fn list_layouts_dto_from_output() {
let out = ListLayoutsOutput {
layouts: vec![
LayoutInfo { id: lid(1), name: "Default".to_owned(), kind: LayoutKind::Terminal },
LayoutInfo { id: lid(2), name: "Backend".to_owned(), kind: LayoutKind::GitGraph },
],
active_id: lid(1),
};
let dto = ListLayoutsDto::from(out);
let v = serde_json::to_value(&dto).unwrap();
let layouts = v["layouts"].as_array().unwrap();
assert_eq!(layouts.len(), 2);
assert_eq!(layouts[0]["name"], "Default");
assert_eq!(layouts[0]["kind"], "terminal");
assert_eq!(layouts[1]["name"], "Backend");
assert_eq!(layouts[1]["kind"], "gitGraph");
assert_eq!(v["activeId"], lid(1).to_string(), "camelCase activeId");
assert!(v.get("active_id").is_none(), "no snake_case leak");
}
// ---------------------------------------------------------------------------
// CreateLayoutResultDto
// ---------------------------------------------------------------------------
#[test]
fn create_layout_result_dto_from_output() {
let out = CreateLayoutOutput { layout_id: lid(42) };
let dto = CreateLayoutResultDto::from(out);
let v = serde_json::to_value(&dto).unwrap();
assert_eq!(v["layoutId"], lid(42).to_string(), "camelCase layoutId");
assert!(v.get("layout_id").is_none(), "no snake_case leak");
}
// ---------------------------------------------------------------------------
// DeleteLayoutResultDto
// ---------------------------------------------------------------------------
#[test]
fn delete_layout_result_dto_from_output() {
let out = DeleteLayoutOutput { active_id: lid(1) };
let dto = DeleteLayoutResultDto::from(out);
let v = serde_json::to_value(&dto).unwrap();
assert_eq!(v["activeId"], lid(1).to_string(), "camelCase activeId");
}
// ---------------------------------------------------------------------------
// Request DTO deserialisation
// ---------------------------------------------------------------------------
#[test]
fn create_layout_request_deserialises_camelcase() {
let project_id = Uuid::from_u128(10).to_string();
let raw = json!({ "projectId": project_id, "name": "Backend" });
let dto: CreateLayoutRequestDto = serde_json::from_value(raw).unwrap();
assert_eq!(dto.project_id, project_id);
assert_eq!(dto.name, "Backend");
assert!(dto.kind.is_none(), "kind defaults to None when absent");
}
#[test]
fn create_layout_request_with_git_graph_kind() {
let project_id = Uuid::from_u128(11).to_string();
let raw = json!({ "projectId": project_id, "name": "Graph", "kind": "gitGraph" });
let dto: CreateLayoutRequestDto = serde_json::from_value(raw).unwrap();
assert_eq!(dto.kind.as_deref(), Some("gitGraph"));
let kind = dto.parse_kind().unwrap();
assert_eq!(kind, application::LayoutKind::GitGraph);
}
#[test]
fn create_layout_request_unknown_kind_is_invalid() {
let project_id = Uuid::from_u128(12).to_string();
let raw = json!({ "projectId": project_id, "name": "X", "kind": "unknown" });
let dto: CreateLayoutRequestDto = serde_json::from_value(raw).unwrap();
let err = dto.parse_kind().unwrap_err();
assert_eq!(err.code, "INVALID");
}
#[test]
fn rename_layout_request_deserialises_camelcase() {
let raw = json!({
"projectId": Uuid::nil().to_string(),
"layoutId": Uuid::nil().to_string(),
"name": "Renamed"
});
let dto: RenameLayoutRequestDto = serde_json::from_value(raw).unwrap();
assert_eq!(dto.name, "Renamed");
}
#[test]
fn delete_layout_request_deserialises_camelcase() {
let project_id = Uuid::from_u128(1).to_string();
let layout_id = Uuid::from_u128(2).to_string();
let raw = json!({ "projectId": project_id, "layoutId": layout_id });
let dto: DeleteLayoutRequestDto = serde_json::from_value(raw).unwrap();
assert_eq!(dto.project_id, project_id);
assert_eq!(dto.layout_id, layout_id);
}
#[test]
fn set_active_layout_request_deserialises_camelcase() {
let raw = json!({
"projectId": Uuid::nil().to_string(),
"layoutId": Uuid::nil().to_string()
});
let _dto: SetActiveLayoutRequestDto = serde_json::from_value(raw).unwrap();
}
// ---------------------------------------------------------------------------
// parse_layout_id
// ---------------------------------------------------------------------------
#[test]
fn parse_layout_id_accepts_uuid() {
let id = Uuid::from_u128(5);
assert_eq!(
parse_layout_id(&id.to_string()).unwrap(),
LayoutId::from_uuid(id)
);
}
#[test]
fn parse_layout_id_rejects_garbage() {
let err = parse_layout_id("not-a-uuid").expect_err("garbage rejected");
assert_eq!(err.code, "INVALID");
}
// ---------------------------------------------------------------------------
// setCellAgent operation deserialisation (#3)
// ---------------------------------------------------------------------------
#[test]
fn set_cell_agent_op_deserialises_with_agent() {
let target = nid(1);
let agent = aid(99);
let raw = json!({
"type": "setCellAgent",
"target": target.to_string(),
"agent": agent.to_string()
});
let dto: LayoutOperationDto = serde_json::from_value(raw).unwrap();
let op = dto.into_operation().unwrap();
match op {
application::LayoutOperation::SetCellAgent { target: t, agent: a } => {
assert_eq!(t, target);
assert_eq!(a, Some(agent));
}
_ => panic!("expected SetCellAgent"),
}
}
#[test]
fn set_cell_agent_op_deserialises_with_null_agent() {
let target = nid(1);
let raw = json!({
"type": "setCellAgent",
"target": target.to_string(),
"agent": null
});
let dto: LayoutOperationDto = serde_json::from_value(raw).unwrap();
let op = dto.into_operation().unwrap();
match op {
application::LayoutOperation::SetCellAgent { target: t, agent: None } => {
assert_eq!(t, target);
}
_ => panic!("expected SetCellAgent with None agent"),
}
}
#[test]
fn set_cell_agent_op_deserialises_with_absent_agent_defaults_to_none() {
let target = nid(2);
// omitting `agent` should default to None
let raw = json!({
"type": "setCellAgent",
"target": target.to_string()
});
let dto: LayoutOperationDto = serde_json::from_value(raw).unwrap();
let op = dto.into_operation().unwrap();
match op {
application::LayoutOperation::SetCellAgent { agent: None, .. } => {}
_ => panic!("expected SetCellAgent with None agent"),
}
}

View File

@ -0,0 +1,133 @@
//! L5 tests for the profile/first-run DTO (de)serialisation contract: camelCase
//! on the wire, embedded [`AgentProfile`] shape preserved, and `parse_profile_id`
//! error behaviour.
use app_tauri_lib::dto::{
parse_delete_profile, parse_profile_id, ConfigureProfilesRequestDto, DetectProfilesRequestDto,
DetectProfilesResponseDto, FirstRunStateDto, ProfileListDto, SaveProfileRequestDto,
};
use application::{
ConfigureProfilesInput, DetectProfilesInput, DetectProfilesOutput, FirstRunStateOutput,
ProfileAvailability, SaveProfileInput,
};
use domain::ids::ProfileId;
use domain::profile::{AgentProfile, ContextInjection};
use serde_json::json;
use uuid::Uuid;
fn profile(id: u128, name: &str, command: &str) -> AgentProfile {
AgentProfile::new(
ProfileId::from_uuid(Uuid::from_u128(id)),
name,
command,
Vec::new(),
ContextInjection::convention_file("CLAUDE.md").unwrap(),
Some(format!("{command} --version")),
"{projectRoot}",
)
.unwrap()
}
#[test]
fn profile_list_dto_serialises_camelcase_profiles() {
let dto: ProfileListDto = vec![profile(1, "Claude", "claude")].into();
let v = serde_json::to_value(&dto).unwrap();
let arr = v.as_array().expect("transparent array");
assert_eq!(arr.len(), 1);
assert_eq!(arr[0]["command"], "claude");
assert_eq!(arr[0]["cwdTemplate"], "{projectRoot}");
assert_eq!(arr[0]["contextInjection"]["strategy"], "conventionFile");
}
#[test]
fn detect_request_deserialises_candidates() {
let raw = json!({
"candidates": [{
"id": Uuid::from_u128(1).to_string(),
"name": "Claude",
"command": "claude",
"args": [],
"contextInjection": { "strategy": "stdin" },
"detect": "claude --version",
"cwdTemplate": "{projectRoot}"
}]
});
let dto: DetectProfilesRequestDto = serde_json::from_value(raw).unwrap();
let input: DetectProfilesInput = dto.into();
assert_eq!(input.candidates.len(), 1);
assert_eq!(input.candidates[0].command, "claude");
}
#[test]
fn detect_response_serialises_available_flag_camelcase() {
let out = DetectProfilesOutput {
results: vec![ProfileAvailability {
profile: profile(1, "Claude", "claude"),
available: true,
}],
};
let dto: DetectProfilesResponseDto = out.into();
let v = serde_json::to_value(&dto).unwrap();
let arr = v.as_array().unwrap();
assert_eq!(arr[0]["available"], true);
assert_eq!(arr[0]["profile"]["command"], "claude");
}
#[test]
fn save_request_deserialises_profile() {
let raw = json!({
"profile": {
"id": Uuid::from_u128(2).to_string(),
"name": "Codex",
"command": "codex",
"args": ["--foo"],
"contextInjection": { "strategy": "conventionFile", "target": "AGENTS.md" },
"detect": null,
"cwdTemplate": ""
}
});
let dto: SaveProfileRequestDto = serde_json::from_value(raw).unwrap();
let input: SaveProfileInput = dto.into();
assert_eq!(input.profile.command, "codex");
assert_eq!(input.profile.args, vec!["--foo"]);
assert!(input.profile.detect.is_none());
}
#[test]
fn configure_request_deserialises_profiles() {
let raw = json!({ "profiles": [] });
let dto: ConfigureProfilesRequestDto = serde_json::from_value(raw).unwrap();
let input: ConfigureProfilesInput = dto.into();
assert!(input.profiles.is_empty());
}
#[test]
fn first_run_state_dto_serialises_camelcase() {
let out = FirstRunStateOutput {
is_first_run: true,
reference_profiles: vec![profile(1, "Claude", "claude")],
};
let dto: FirstRunStateDto = out.into();
let v = serde_json::to_value(&dto).unwrap();
assert_eq!(v["isFirstRun"], true);
assert!(v.get("is_first_run").is_none(), "no snake_case leak");
assert_eq!(v["referenceProfiles"][0]["command"], "claude");
}
#[test]
fn parse_profile_id_accepts_uuid_and_rejects_garbage() {
let id = Uuid::from_u128(7);
assert_eq!(
parse_profile_id(&id.to_string()).unwrap(),
ProfileId::from_uuid(id)
);
let err = parse_profile_id("not-a-uuid").expect_err("garbage rejected");
assert_eq!(err.code, "INVALID");
}
#[test]
fn parse_delete_profile_builds_input() {
let id = Uuid::from_u128(9);
let input = parse_delete_profile(&id.to_string()).unwrap();
assert_eq!(input.id, ProfileId::from_uuid(id));
}

View File

@ -0,0 +1,275 @@
//! L7 tests for template & sync DTO (de)serialisation contract: camelCase on
//! the wire, `TemplateDto`/`TemplateListDto` shapes, `AgentDriftDto`/
//! `AgentDriftListDto`, `SyncResultDto`, request DTO deserialisation, and
//! `parse_template_id` error behaviour. No Tauri runtime required.
use app_tauri_lib::dto::{
parse_template_id, AgentDriftDto, AgentDriftListDto, CreateAgentFromTemplateRequestDto,
CreateTemplateRequestDto, SyncResultDto, TemplateDto, TemplateListDto,
UpdateTemplateRequestDto,
};
use application::{
AgentDrift, CreateTemplateOutput, DetectAgentDriftOutput, ListTemplatesOutput,
SyncAgentWithTemplateOutput, UpdateTemplateOutput,
};
use domain::ids::{AgentId, ProfileId, TemplateId};
use domain::markdown::MarkdownDoc;
use domain::template::{AgentTemplate, TemplateVersion};
use serde_json::json;
use uuid::Uuid;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
fn make_template(template_uuid: u128, profile_uuid: u128) -> AgentTemplate {
AgentTemplate::new(
TemplateId::from_uuid(Uuid::from_u128(template_uuid)),
"My Template",
MarkdownDoc::new("# Hello".to_owned()),
ProfileId::from_uuid(Uuid::from_u128(profile_uuid)),
)
.expect("valid template")
}
// ---------------------------------------------------------------------------
// TemplateDto serialisation
// ---------------------------------------------------------------------------
#[test]
fn template_dto_serialises_camelcase() {
let tmpl = make_template(1, 2);
let dto = TemplateDto(tmpl.clone());
let v = serde_json::to_value(&dto).unwrap();
assert_eq!(v["id"], tmpl.id.to_string());
assert_eq!(v["name"], "My Template");
// contentMd is the camelCase field on AgentTemplate; MarkdownDoc is transparent → plain string
assert_eq!(v["contentMd"], "# Hello");
// version is a transparent number
assert_eq!(v["version"], 1u64);
assert_eq!(
v["defaultProfileId"],
ProfileId::from_uuid(Uuid::from_u128(2)).to_string()
);
// no snake_case leak
assert!(v.get("content_md").is_none(), "no snake_case leak for contentMd");
assert!(v.get("default_profile_id").is_none(), "no snake_case leak for defaultProfileId");
}
#[test]
fn template_dto_version_is_number() {
let tmpl = make_template(3, 4);
let dto = TemplateDto(tmpl);
let v = serde_json::to_value(&dto).unwrap();
assert!(v["version"].is_number(), "version should be a JSON number");
assert_eq!(v["version"].as_u64().unwrap(), 1);
}
// ---------------------------------------------------------------------------
// TemplateListDto
// ---------------------------------------------------------------------------
#[test]
fn template_list_dto_is_transparent_array() {
let out = ListTemplatesOutput {
templates: vec![make_template(1, 2), make_template(3, 4)],
};
let dto = TemplateListDto::from(out);
let v = serde_json::to_value(&dto).unwrap();
let arr = v.as_array().expect("transparent array");
assert_eq!(arr.len(), 2);
assert_eq!(arr[0]["name"], "My Template");
assert_eq!(arr[1]["name"], "My Template");
}
#[test]
fn template_list_dto_empty() {
let out = ListTemplatesOutput { templates: vec![] };
let dto = TemplateListDto::from(out);
let v = serde_json::to_value(&dto).unwrap();
assert!(v.as_array().unwrap().is_empty());
}
// ---------------------------------------------------------------------------
// From<CreateTemplateOutput> / From<UpdateTemplateOutput>
// ---------------------------------------------------------------------------
#[test]
fn create_template_output_maps_to_template_dto() {
let tmpl = make_template(5, 6);
let out = CreateTemplateOutput { template: tmpl.clone() };
let dto = TemplateDto::from(out);
assert_eq!(dto.0.id, tmpl.id);
}
#[test]
fn update_template_output_maps_to_template_dto() {
let tmpl = make_template(7, 8);
let bumped = tmpl.with_updated_content(MarkdownDoc::new("# Updated".to_owned()));
let out = UpdateTemplateOutput { template: bumped.clone() };
let dto = TemplateDto::from(out);
assert_eq!(dto.0.version, TemplateVersion(2));
assert_eq!(dto.0.id, bumped.id);
}
// ---------------------------------------------------------------------------
// AgentDriftDto / AgentDriftListDto serialisation
// ---------------------------------------------------------------------------
#[test]
fn agent_drift_dto_serialises_camelcase() {
let agent_id = AgentId::from_uuid(Uuid::from_u128(10));
let drift = AgentDrift {
agent_id,
from: TemplateVersion(1),
to: TemplateVersion(3),
};
let dto = AgentDriftDto::from(drift);
let v = serde_json::to_value(&dto).unwrap();
assert_eq!(v["agentId"], agent_id.to_string());
assert_eq!(v["from"], 1u64);
assert_eq!(v["to"], 3u64);
// no snake_case leak
assert!(v.get("agent_id").is_none(), "no snake_case leak for agentId");
}
#[test]
fn agent_drift_list_dto_is_transparent_array() {
let agent_id = AgentId::from_uuid(Uuid::from_u128(11));
let out = DetectAgentDriftOutput {
drifts: vec![AgentDrift {
agent_id,
from: TemplateVersion(2),
to: TemplateVersion(5),
}],
};
let dto = AgentDriftListDto::from(out);
let v = serde_json::to_value(&dto).unwrap();
let arr = v.as_array().expect("transparent array");
assert_eq!(arr.len(), 1);
assert_eq!(arr[0]["from"], 2u64);
assert_eq!(arr[0]["to"], 5u64);
}
#[test]
fn agent_drift_list_dto_empty() {
let out = DetectAgentDriftOutput { drifts: vec![] };
let dto = AgentDriftListDto::from(out);
let v = serde_json::to_value(&dto).unwrap();
assert!(v.as_array().unwrap().is_empty());
}
// ---------------------------------------------------------------------------
// SyncResultDto
// ---------------------------------------------------------------------------
#[test]
fn sync_result_dto_synced_with_version() {
let out = SyncAgentWithTemplateOutput {
synced: true,
version: Some(TemplateVersion(4)),
};
let dto = SyncResultDto::from(out);
let v = serde_json::to_value(&dto).unwrap();
assert_eq!(v["synced"], true);
assert_eq!(v["version"], 4u64);
}
#[test]
fn sync_result_dto_not_synced_version_null() {
let out = SyncAgentWithTemplateOutput {
synced: false,
version: None,
};
let dto = SyncResultDto::from(out);
let v = serde_json::to_value(&dto).unwrap();
assert_eq!(v["synced"], false);
assert!(v["version"].is_null(), "version should be null when None");
}
// ---------------------------------------------------------------------------
// Request DTO deserialisation
// ---------------------------------------------------------------------------
#[test]
fn create_template_request_deserialises_camelcase() {
let profile_id = Uuid::from_u128(20).to_string();
let raw = json!({
"name": "Backend Template",
"content": "# My context",
"defaultProfileId": profile_id
});
let dto: CreateTemplateRequestDto = serde_json::from_value(raw).unwrap();
assert_eq!(dto.name, "Backend Template");
assert_eq!(dto.content, "# My context");
assert_eq!(dto.default_profile_id, profile_id);
}
#[test]
fn update_template_request_deserialises_camelcase() {
let template_id = Uuid::from_u128(30).to_string();
let raw = json!({
"templateId": template_id,
"content": "# Updated content"
});
let dto: UpdateTemplateRequestDto = serde_json::from_value(raw).unwrap();
assert_eq!(dto.template_id, template_id);
assert_eq!(dto.content, "# Updated content");
}
#[test]
fn create_agent_from_template_request_deserialises_camelcase() {
let project_id = Uuid::from_u128(40).to_string();
let template_id = Uuid::from_u128(41).to_string();
let raw = json!({
"projectId": project_id,
"templateId": template_id,
"name": "My Agent",
"synchronized": true
});
let dto: CreateAgentFromTemplateRequestDto = serde_json::from_value(raw).unwrap();
assert_eq!(dto.project_id, project_id);
assert_eq!(dto.template_id, template_id);
assert_eq!(dto.name.as_deref(), Some("My Agent"));
assert!(dto.synchronized);
}
#[test]
fn create_agent_from_template_request_name_defaults_to_none() {
let raw = json!({
"projectId": Uuid::nil().to_string(),
"templateId": Uuid::nil().to_string(),
"synchronized": false
});
let dto: CreateAgentFromTemplateRequestDto = serde_json::from_value(raw).unwrap();
assert!(dto.name.is_none());
}
// ---------------------------------------------------------------------------
// parse_template_id
// ---------------------------------------------------------------------------
#[test]
fn parse_template_id_accepts_uuid() {
let id = Uuid::from_u128(99);
assert_eq!(
parse_template_id(&id.to_string()).unwrap(),
TemplateId::from_uuid(id)
);
}
#[test]
fn parse_template_id_rejects_garbage() {
let err = parse_template_id("INVALID").expect_err("garbage rejected");
assert_eq!(err.code, "INVALID");
}
#[test]
fn parse_template_id_rejects_empty() {
let err = parse_template_id("").expect_err("empty string rejected");
assert_eq!(err.code, "INVALID");
}

View File

@ -0,0 +1,28 @@
//! L10 DTO tests: `move_tab_to_new_window` result mapping + tab-id parsing.
use app_tauri_lib::dto::{parse_tab_id, MoveTabResultDto};
use application::MoveTabToNewWindowOutput;
use domain::ids::WindowId;
use domain::layout::Workspace;
use uuid::Uuid;
#[test]
fn move_tab_result_serializes_new_window_id_camel_case() {
let out = MoveTabToNewWindowOutput {
new_window_id: WindowId::from_uuid(Uuid::from_u128(42)),
workspace: Workspace::default(),
};
let dto = MoveTabResultDto::from(out);
let json = serde_json::to_string(&dto).unwrap();
assert!(json.contains("\"newWindowId\""), "json was {json}");
assert!(!json.contains("new_window_id"), "no snake_case leak: {json}");
}
#[test]
fn parse_tab_id_accepts_uuid_and_rejects_garbage() {
let uuid = Uuid::from_u128(7).to_string();
assert!(parse_tab_id(&uuid).is_ok());
let err = parse_tab_id("not-a-uuid").unwrap_err();
assert_eq!(err.code, "INVALID");
}

View File

@ -0,0 +1,90 @@
//! L1 tests for [`PtyBridge`] — the PTY↔Channel registry — exercised with a
//! real [`tauri::ipc::Channel`] built from a capturing closure (no Tauri runtime
//! and no real PTY needed).
use std::sync::{Arc, Mutex};
use tauri::ipc::{Channel, InvokeResponseBody};
use app_tauri_lib::pty::PtyBridge;
use domain::ids::SessionId;
use uuid::Uuid;
/// Builds a `Channel<Vec<u8>>` whose sent chunks are recorded into `sink`.
///
/// `Vec<u8>` is `Serialize`, so chunks arrive as a JSON array string in an
/// `InvokeResponseBody::Json`; we parse them back to bytes for assertions.
fn capturing_channel(sink: Arc<Mutex<Vec<Vec<u8>>>>) -> Channel<Vec<u8>> {
Channel::new(move |body: InvokeResponseBody| {
let bytes: Vec<u8> = match body {
InvokeResponseBody::Json(s) => serde_json::from_str(&s).unwrap(),
InvokeResponseBody::Raw(b) => b,
};
sink.lock().unwrap().push(bytes);
Ok(())
})
}
fn sid() -> SessionId {
SessionId::from_uuid(Uuid::new_v4())
}
#[test]
fn register_increases_active_sessions() {
let bridge = PtyBridge::new();
assert_eq!(bridge.active_sessions(), 0);
let sink = Arc::new(Mutex::new(Vec::new()));
bridge.register(sid(), capturing_channel(sink));
assert_eq!(bridge.active_sessions(), 1);
}
#[test]
fn send_output_delivers_bytes_to_registered_channel() {
let bridge = PtyBridge::new();
let session = sid();
let sink = Arc::new(Mutex::new(Vec::new()));
bridge.register(session, capturing_channel(Arc::clone(&sink)));
let delivered = bridge.send_output(&session, vec![104, 105]);
assert!(delivered, "send_output should return true for a live session");
let captured = sink.lock().unwrap();
assert_eq!(captured.as_slice(), &[vec![104, 105]]);
}
#[test]
fn send_output_to_unknown_session_returns_false() {
let bridge = PtyBridge::new();
assert!(!bridge.send_output(&sid(), vec![0]));
}
#[test]
fn unregister_removes_session_and_stops_delivery() {
let bridge = PtyBridge::new();
let session = sid();
let sink = Arc::new(Mutex::new(Vec::new()));
bridge.register(session, capturing_channel(Arc::clone(&sink)));
assert_eq!(bridge.active_sessions(), 1);
bridge.unregister(&session);
assert_eq!(bridge.active_sessions(), 0);
assert!(!bridge.send_output(&session, vec![1]));
assert!(sink.lock().unwrap().is_empty());
}
#[test]
fn register_same_session_twice_replaces_channel() {
let bridge = PtyBridge::new();
let session = sid();
let first = Arc::new(Mutex::new(Vec::new()));
let second = Arc::new(Mutex::new(Vec::new()));
bridge.register(session, capturing_channel(Arc::clone(&first)));
bridge.register(session, capturing_channel(Arc::clone(&second)));
assert_eq!(bridge.active_sessions(), 1, "same id is replaced, not added");
bridge.send_output(&session, vec![9]);
assert!(first.lock().unwrap().is_empty(), "old channel no longer used");
assert_eq!(second.lock().unwrap().as_slice(), &[vec![9]]);
}

View File

@ -0,0 +1,19 @@
[package]
name = "application"
version = "0.1.0"
edition.workspace = true
license.workspace = true
rust-version.workspace = true
description = "IdeA — application layer: use cases, DTOs, AppError. Depends only on domain ports."
[dependencies]
domain = { workspace = true }
thiserror = { workspace = true }
async-trait = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
# `v5` derives stable reference-profile ids from a fixed namespace (catalogue).
uuid = { workspace = true }
[dev-dependencies]
tokio = { workspace = true }

View File

@ -0,0 +1,93 @@
//! Reference profile **catalogue** — the pre-filled, *editable* profiles offered
//! by the first-run wizard (CONTEXT §9, ARCHITECTURE §6 `ConfigureProfiles`).
//!
//! These are **data, not domain code**: the catalogue lives in the application
//! layer (a product decision about *which* AIs to suggest), built from the
//! domain's validating constructors. Nothing is imposed — the user picks, edits
//! the pre-filled commands, and may add custom profiles. The single
//! [`domain::ports::AgentRuntime`] adapter consumes whatever profiles result.
//!
//! Reference set (CONTEXT §9):
//! - **Claude Code** — `claude`, context via `CLAUDE.md` (convention file),
//! - **OpenAI Codex CLI** — `codex`, context via `AGENTS.md`,
//! - **Gemini CLI** — `gemini`, context via `GEMINI.md`,
//! - **Aider** — `aider`, context passed as an argument (`--message-file {path}`).
//!
//! The ids are **stable, deterministic UUIDs** (derived from a fixed namespace)
//! so re-deriving the catalogue yields the same id for "claude" every time,
//! making the reference profiles addressable across runs without a registry.
use domain::ids::ProfileId;
use domain::profile::{AgentProfile, ContextInjection};
/// A fixed UUID namespace used to derive stable ids for reference profiles.
/// (Random-looking but constant; only its stability matters.)
const REFERENCE_NAMESPACE: uuid::Uuid = uuid::uuid!("6f9b1d2a-7c34-4e58-9a1b-2c3d4e5f6a7b");
/// Derives a stable [`ProfileId`] for a reference profile from its slug.
#[must_use]
fn reference_id(slug: &str) -> ProfileId {
ProfileId::from_uuid(uuid::Uuid::new_v5(&REFERENCE_NAMESPACE, slug.as_bytes()))
}
/// Returns the stable id a reference profile slug maps to (exposed for tests and
/// callers that need to address a reference profile).
#[must_use]
pub fn reference_profile_id(slug: &str) -> ProfileId {
reference_id(slug)
}
/// Builds the pre-filled, editable reference profiles (CONTEXT §9).
///
/// # Panics
/// Never in practice: every literal here satisfies the domain invariants, so the
/// constructors cannot fail; the `expect`s document that.
#[must_use]
pub fn reference_profiles() -> Vec<AgentProfile> {
vec![
AgentProfile::new(
reference_id("claude"),
"Claude Code",
"claude",
Vec::new(),
ContextInjection::convention_file("CLAUDE.md")
.expect("CLAUDE.md is a valid convention target"),
Some("claude --version".to_owned()),
"{projectRoot}",
)
.expect("claude reference profile is valid"),
AgentProfile::new(
reference_id("codex"),
"OpenAI Codex CLI",
"codex",
Vec::new(),
ContextInjection::convention_file("AGENTS.md")
.expect("AGENTS.md is a valid convention target"),
Some("codex --version".to_owned()),
"{projectRoot}",
)
.expect("codex reference profile is valid"),
AgentProfile::new(
reference_id("gemini"),
"Gemini CLI",
"gemini",
Vec::new(),
ContextInjection::convention_file("GEMINI.md")
.expect("GEMINI.md is a valid convention target"),
Some("gemini --version".to_owned()),
"{projectRoot}",
)
.expect("gemini reference profile is valid"),
AgentProfile::new(
reference_id("aider"),
"Aider",
"aider",
Vec::new(),
ContextInjection::flag("--message-file {path}")
.expect("aider flag template is non-empty"),
Some("aider --version".to_owned()),
"{projectRoot}",
)
.expect("aider reference profile is valid"),
]
}

View File

@ -0,0 +1,538 @@
//! Agent lifecycle use cases (ARCHITECTURE §6, L6).
//!
//! These own the *project-agent* side (distinct from the profile side in
//! [`super::usecases`]): creating agents and their `.md` contexts under
//! `.ideai/`, listing/reading/updating them, and — the centrepiece —
//! [`LaunchAgent`], which resolves the agent's profile + context, applies the
//! profile's context-injection strategy, opens a PTY cell at the right `cwd` and
//! spawns the CLI.
//!
//! Every use case talks **only to ports** ([`AgentContextStore`], [`ProfileStore`],
//! [`AgentRuntime`], [`PtyPort`], [`FileSystem`], [`EventBus`]); none knows about
//! a concrete adapter or Tauri.
use std::sync::Arc;
use domain::ports::{
AgentContextStore, AgentRuntime, ContextInjectionPlan, EventBus, FileSystem, PreparedContext,
ProfileStore, PtyPort, RemotePath, SpawnSpec,
};
use domain::{
Agent, AgentId, AgentManifest, AgentOrigin, DomainEvent, ManifestEntry, MarkdownDoc, NodeId,
Project, ProfileId, ProjectPath, PtySize, SessionKind, SessionStatus, TerminalSession,
};
use crate::error::AppError;
use crate::terminal::TerminalSessions;
/// Directory (relative to `.ideai/`) under which agent contexts are written.
const AGENTS_SUBDIR: &str = "agents";
// ---------------------------------------------------------------------------
// CreateAgentFromScratch
// ---------------------------------------------------------------------------
/// Input for [`CreateAgentFromScratch::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CreateAgentInput {
/// The project that owns the agent.
pub project: Project,
/// Display name of the agent.
pub name: String,
/// Runtime profile the agent launches with.
pub profile_id: ProfileId,
/// Initial `.md` content (empty when `None`).
pub initial_content: Option<String>,
}
/// Output of [`CreateAgentFromScratch::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CreateAgentOutput {
/// The freshly-created agent.
pub agent: Agent,
}
/// Creates a project agent from scratch: mints an id, derives a unique `.md`
/// path, records the manifest entry, then writes the (possibly empty) context.
pub struct CreateAgentFromScratch {
contexts: Arc<dyn AgentContextStore>,
ids: Arc<dyn domain::ports::IdGenerator>,
events: Arc<dyn EventBus>,
}
impl CreateAgentFromScratch {
/// Builds the use case from its injected ports.
#[must_use]
pub fn new(
contexts: Arc<dyn AgentContextStore>,
ids: Arc<dyn domain::ports::IdGenerator>,
events: Arc<dyn EventBus>,
) -> Self {
Self {
contexts,
ids,
events,
}
}
/// Executes creation.
///
/// Ordering matters: the manifest entry is persisted **before** the context
/// is written, because [`AgentContextStore::write_context`] resolves the
/// on-disk path from the manifest.
///
/// # Errors
/// - [`AppError::Invalid`] if the name is empty or the manifest would become
/// inconsistent,
/// - [`AppError::Store`] on persistence failure.
pub async fn execute(&self, input: CreateAgentInput) -> Result<CreateAgentOutput, AppError> {
let manifest = self.contexts.load_manifest(&input.project).await?;
let id = AgentId::from_uuid(self.ids.new_uuid());
let md_path = unique_md_path(&input.name, &manifest);
let agent = Agent::new(
id,
input.name,
md_path,
input.profile_id,
AgentOrigin::Scratch,
false,
)
.map_err(|e| AppError::Invalid(e.to_string()))?;
// Append the entry and re-validate the whole manifest (unique md_paths).
let mut entries = manifest.entries;
entries.push(ManifestEntry::from_agent(&agent));
let manifest = AgentManifest::new(manifest.version, entries)
.map_err(|e| AppError::Invalid(e.to_string()))?;
self.contexts.save_manifest(&input.project, &manifest).await?;
// Now the path resolves: write the initial context.
let md = MarkdownDoc::new(input.initial_content.unwrap_or_default());
self.contexts
.write_context(&input.project, &agent.id, &md)
.await?;
self.events.publish(DomainEvent::LayoutChanged {
project_id: input.project.id,
});
Ok(CreateAgentOutput { agent })
}
}
// ---------------------------------------------------------------------------
// ListAgents
// ---------------------------------------------------------------------------
/// Input for [`ListAgents::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ListAgentsInput {
/// The project whose agents to list.
pub project: Project,
}
/// Output of [`ListAgents::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ListAgentsOutput {
/// The project's agents (reconstructed from the manifest).
pub agents: Vec<Agent>,
}
/// Lists a project's agents by reconstructing them from the manifest entries.
pub struct ListAgents {
contexts: Arc<dyn AgentContextStore>,
}
impl ListAgents {
/// Builds the use case from the [`AgentContextStore`] port.
#[must_use]
pub fn new(contexts: Arc<dyn AgentContextStore>) -> Self {
Self { contexts }
}
/// Loads the manifest and folds each entry back into an [`Agent`].
///
/// # Errors
/// - [`AppError::Store`] on persistence failure,
/// - [`AppError::Invalid`] if a persisted entry violates an agent invariant.
pub async fn execute(&self, input: ListAgentsInput) -> Result<ListAgentsOutput, AppError> {
let manifest = self.contexts.load_manifest(&input.project).await?;
let agents = manifest
.entries
.iter()
.map(|e| e.to_agent().map_err(|err| AppError::Invalid(err.to_string())))
.collect::<Result<Vec<_>, _>>()?;
Ok(ListAgentsOutput { agents })
}
}
// ---------------------------------------------------------------------------
// ReadAgentContext / UpdateAgentContext
// ---------------------------------------------------------------------------
/// Input for [`ReadAgentContext::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReadAgentContextInput {
/// The owning project.
pub project: Project,
/// The agent whose `.md` to read.
pub agent_id: AgentId,
}
/// Output of [`ReadAgentContext::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReadAgentContextOutput {
/// The agent's Markdown context.
pub content: MarkdownDoc,
}
/// Reads an agent's `.md` context.
pub struct ReadAgentContext {
contexts: Arc<dyn AgentContextStore>,
}
impl ReadAgentContext {
/// Builds the use case.
#[must_use]
pub fn new(contexts: Arc<dyn AgentContextStore>) -> Self {
Self { contexts }
}
/// Reads the context.
///
/// # Errors
/// - [`AppError::NotFound`] if the agent (or its `.md`) is unknown,
/// - [`AppError::Store`] on persistence failure.
pub async fn execute(
&self,
input: ReadAgentContextInput,
) -> Result<ReadAgentContextOutput, AppError> {
let content = self
.contexts
.read_context(&input.project, &input.agent_id)
.await?;
Ok(ReadAgentContextOutput { content })
}
}
/// Input for [`UpdateAgentContext::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UpdateAgentContextInput {
/// The owning project.
pub project: Project,
/// The agent whose `.md` to overwrite.
pub agent_id: AgentId,
/// New Markdown content.
pub content: String,
}
/// Overwrites an agent's `.md` context.
pub struct UpdateAgentContext {
contexts: Arc<dyn AgentContextStore>,
}
impl UpdateAgentContext {
/// Builds the use case.
#[must_use]
pub fn new(contexts: Arc<dyn AgentContextStore>) -> Self {
Self { contexts }
}
/// Writes the new context.
///
/// # Errors
/// - [`AppError::NotFound`] if the agent is unknown,
/// - [`AppError::Store`] on persistence failure.
pub async fn execute(&self, input: UpdateAgentContextInput) -> Result<(), AppError> {
let md = MarkdownDoc::new(input.content);
self.contexts
.write_context(&input.project, &input.agent_id, &md)
.await?;
Ok(())
}
}
// ---------------------------------------------------------------------------
// DeleteAgent
// ---------------------------------------------------------------------------
/// Input for [`DeleteAgent::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DeleteAgentInput {
/// The owning project.
pub project: Project,
/// The agent to remove.
pub agent_id: AgentId,
}
/// Removes an agent from the project manifest.
///
/// The orphaned `.md` file is left on disk: the [`FileSystem`] port exposes no
/// delete, and keeping the file is the safe default (the user may want to recover
/// the context). Re-creating an agent with the same name reuses a fresh path.
pub struct DeleteAgent {
contexts: Arc<dyn AgentContextStore>,
events: Arc<dyn EventBus>,
}
impl DeleteAgent {
/// Builds the use case.
#[must_use]
pub fn new(contexts: Arc<dyn AgentContextStore>, events: Arc<dyn EventBus>) -> Self {
Self { contexts, events }
}
/// Drops the manifest entry for the agent.
///
/// # Errors
/// - [`AppError::NotFound`] if the agent is not in the manifest,
/// - [`AppError::Store`] on persistence failure.
pub async fn execute(&self, input: DeleteAgentInput) -> Result<(), AppError> {
let manifest = self.contexts.load_manifest(&input.project).await?;
let before = manifest.entries.len();
let entries: Vec<ManifestEntry> = manifest
.entries
.into_iter()
.filter(|e| e.agent_id != input.agent_id)
.collect();
if entries.len() == before {
return Err(AppError::NotFound(format!("agent {}", input.agent_id)));
}
let manifest = AgentManifest::new(manifest.version, entries)
.map_err(|e| AppError::Invalid(e.to_string()))?;
self.contexts.save_manifest(&input.project, &manifest).await?;
self.events.publish(DomainEvent::LayoutChanged {
project_id: input.project.id,
});
Ok(())
}
}
// ---------------------------------------------------------------------------
// LaunchAgent
// ---------------------------------------------------------------------------
/// Input for [`LaunchAgent::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LaunchAgentInput {
/// The owning project.
pub project: Project,
/// The agent to launch.
pub agent_id: AgentId,
/// Initial terminal height in rows.
pub rows: u16,
/// Initial terminal width in columns.
pub cols: u16,
/// The layout leaf hosting the session (a fresh node when `None`).
pub node_id: Option<NodeId>,
}
/// Output of [`LaunchAgent::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LaunchAgentOutput {
/// The created agent terminal session.
pub session: TerminalSession,
}
/// Launches an agent: resolve profile + context, prepare the invocation, apply
/// the context-injection plan, open a PTY at the resolved `cwd`, spawn the CLI.
///
/// This is the orchestrating use case of L6 and therefore consumes several ports
/// — each only for the slice it needs (Interface Segregation): the context store
/// (agent `.md` + manifest), the profile store (resolve the runtime), the runtime
/// (build the [`SpawnSpec`]), the filesystem (materialise a `conventionFile`
/// context), and the PTY (spawn + optional stdin injection).
pub struct LaunchAgent {
contexts: Arc<dyn AgentContextStore>,
profiles: Arc<dyn ProfileStore>,
runtime: Arc<dyn AgentRuntime>,
fs: Arc<dyn FileSystem>,
pty: Arc<dyn PtyPort>,
sessions: Arc<TerminalSessions>,
events: Arc<dyn EventBus>,
}
impl LaunchAgent {
/// Builds the use case from its injected ports.
#[must_use]
#[allow(clippy::too_many_arguments)]
pub fn new(
contexts: Arc<dyn AgentContextStore>,
profiles: Arc<dyn ProfileStore>,
runtime: Arc<dyn AgentRuntime>,
fs: Arc<dyn FileSystem>,
pty: Arc<dyn PtyPort>,
sessions: Arc<TerminalSessions>,
events: Arc<dyn EventBus>,
) -> Self {
Self {
contexts,
profiles,
runtime,
fs,
pty,
sessions,
events,
}
}
/// Executes the launch.
///
/// Step order is contractually significant (and unit-tested): resolve the
/// agent + context, **`prepare_invocation`**, **apply the injection plan**
/// (write a `conventionFile` / set an env var), then **`pty.spawn`** at the
/// resolved `cwd`, and finally pipe the context on stdin for the `Stdin`
/// strategy.
///
/// # Errors
/// - [`AppError::NotFound`] if the agent or its profile is unknown,
/// - [`AppError::Invalid`] for a zero-sized terminal,
/// - [`AppError::Store`] / [`AppError::FileSystem`] / [`AppError::Process`] on
/// the respective port failures.
pub async fn execute(&self, input: LaunchAgentInput) -> Result<LaunchAgentOutput, AppError> {
let size =
PtySize::new(input.rows, input.cols).map_err(|e| AppError::Invalid(e.to_string()))?;
// 1. Resolve the agent from the manifest (name + profile + md_path).
let manifest = self.contexts.load_manifest(&input.project).await?;
let entry = manifest
.entries
.iter()
.find(|e| e.agent_id == input.agent_id)
.ok_or_else(|| AppError::NotFound(format!("agent {}", input.agent_id)))?;
let agent = entry
.to_agent()
.map_err(|e| AppError::Invalid(e.to_string()))?;
// 2. Read its context and resolve its profile.
let content = self
.contexts
.read_context(&input.project, &agent.id)
.await?;
let profile = self
.profiles
.list()
.await?
.into_iter()
.find(|p| p.id == agent.profile_id)
.ok_or_else(|| {
AppError::NotFound(format!("profile {} for agent", agent.profile_id))
})?;
// 3. Prepare the invocation (pure): command + args + injection plan + cwd.
let prepared = PreparedContext {
content: content.clone(),
relative_path: agent.context_path.clone(),
};
let mut spec = self
.runtime
.prepare_invocation(&profile, &prepared, &input.project.root)?;
// 4. Apply the injection plan side effects *before* spawning.
self.apply_injection(&input.project, &agent.context_path, &content, &mut spec)
.await?;
// 5. Spawn the PTY at the resolved cwd; adopt its session id everywhere.
let handle = self.pty.spawn(spec.clone(), size).await?;
let session_id = handle.session_id;
// 6. For the Stdin strategy, pipe the context once the PTY is live.
if matches!(spec.context_plan, Some(ContextInjectionPlan::Stdin)) {
self.pty.write(&handle, content.as_str().as_bytes())?;
}
let node_id = input.node_id.unwrap_or_else(NodeId::new_random);
let mut session = TerminalSession::starting(
session_id,
node_id,
spec.cwd.clone(),
SessionKind::Agent {
agent_id: agent.id,
},
size,
);
session.status = SessionStatus::Running;
self.sessions.insert(handle, session.clone());
self.events.publish(DomainEvent::AgentLaunched {
agent_id: agent.id,
session_id,
});
Ok(LaunchAgentOutput { session })
}
/// Applies the context-injection plan that must happen *before* spawn:
/// materialising a `conventionFile` context (write the `.md` to `<cwd>/target`)
/// or attaching the on-disk context path to an environment variable. `Args` is
/// already folded into the spec by the runtime; `Stdin` is handled post-spawn.
async fn apply_injection(
&self,
project: &Project,
context_rel_path: &str,
content: &MarkdownDoc,
spec: &mut SpawnSpec,
) -> Result<(), AppError> {
match spec.context_plan.clone() {
Some(ContextInjectionPlan::File { target }) => {
// conventionFile spike (ARCHITECTURE §13.6): copy the context to the
// conventional file (e.g. CLAUDE.md), overwriting any existing one.
// A copy (not a symlink) is the portable choice — Windows symlinks
// need privileges and SFTP/WSL symlink semantics differ.
let path = RemotePath::new(join(&spec.cwd, &target));
self.fs.write(&path, content.as_str().as_bytes()).await?;
}
Some(ContextInjectionPlan::Env { var }) => {
// Hand the CLI the absolute path of the agent's `.md` (which lives at
// `<root>/.ideai/<context_rel_path>`) via the environment variable.
let abspath = join(&project.root, &format!(".ideai/{context_rel_path}"));
spec.env.push((var, abspath));
}
// Args were folded into spec.args by prepare_invocation; Stdin is
// applied after the PTY is live.
Some(ContextInjectionPlan::Args { .. }) | Some(ContextInjectionPlan::Stdin) | None => {}
}
Ok(())
}
}
/// Builds an absolute path string by joining a [`ProjectPath`] with a relative
/// segment using a POSIX separator.
fn join(base: &ProjectPath, rel: &str) -> String {
let b = base.as_str().trim_end_matches(['/', '\\']);
format!("{b}/{rel}")
}
/// Derives a unique, filesystem-safe `md_path` (`agents/<slug>.md`) for a new
/// agent, disambiguating against the manifest's existing paths with a numeric
/// suffix when needed. Shared with the template-driven agent creation (L7).
pub(crate) fn unique_md_path(name: &str, manifest: &AgentManifest) -> String {
let slug = slugify(name);
let base = if slug.is_empty() { "agent".to_owned() } else { slug };
let mut candidate = format!("{AGENTS_SUBDIR}/{base}.md");
let mut n = 2;
while manifest.entries.iter().any(|e| e.md_path == candidate) {
candidate = format!("{AGENTS_SUBDIR}/{base}-{n}.md");
n += 1;
}
candidate
}
/// Lowercases and slugifies a display name into a safe file stem
/// (`[a-z0-9-]`), collapsing runs of separators.
fn slugify(name: &str) -> String {
let mut out = String::with_capacity(name.len());
let mut prev_dash = false;
for ch in name.trim().chars() {
if ch.is_ascii_alphanumeric() {
out.push(ch.to_ascii_lowercase());
prev_dash = false;
} else if !prev_dash {
out.push('-');
prev_dash = true;
}
}
out.trim_matches('-').to_owned()
}

View File

@ -0,0 +1,27 @@
//! Agent-profile use cases & reference catalogue (ARCHITECTURE §6, L5).
//!
//! This module owns the *profile* side of the AI runtime: detecting which CLIs
//! are installed, persisting the chosen/edited/custom profiles, and exposing the
//! pre-filled reference catalogue that seeds the first-run wizard. It talks only
//! to the [`domain::ports::AgentRuntime`] and [`domain::ports::ProfileStore`]
//! ports. Launching an agent (PTY + injection) is L6.
mod catalogue;
mod lifecycle;
mod usecases;
pub(crate) use lifecycle::unique_md_path;
pub use catalogue::{reference_profile_id, reference_profiles};
pub use lifecycle::{
CreateAgentFromScratch, CreateAgentInput, CreateAgentOutput, DeleteAgent, DeleteAgentInput,
LaunchAgent, LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput, ListAgentsOutput,
ReadAgentContext, ReadAgentContextInput, ReadAgentContextOutput, UpdateAgentContext,
UpdateAgentContextInput,
};
pub use usecases::{
ConfigureProfiles, ConfigureProfilesInput, ConfigureProfilesOutput, DeleteProfile,
DeleteProfileInput, DetectProfiles, DetectProfilesInput, DetectProfilesOutput, FirstRunState,
FirstRunStateOutput, ListProfiles, ListProfilesOutput, ProfileAvailability, ReferenceProfiles,
ReferenceProfilesOutput, SaveProfile, SaveProfileInput, SaveProfileOutput,
};

View File

@ -0,0 +1,323 @@
//! Profile use cases (ARCHITECTURE §6, L5). Each is a single-responsibility
//! struct carrying its ports as `Arc<dyn Port>` and exposing one `execute`.
//!
//! - [`DetectProfiles`] — probe a set of candidate profiles via [`AgentRuntime`]
//! and report which CLIs are installed (first-run availability ✓/✗).
//! - [`ListProfiles`] / [`SaveProfile`] / [`DeleteProfile`] — CRUD over the
//! persisted profiles through the [`ProfileStore`].
//! - [`ConfigureProfiles`] — persist a batch of chosen/edited/custom profiles
//! (closes the first-run wizard).
//! - [`ReferenceProfiles`] — expose the pre-filled, editable catalogue.
//! - [`FirstRunState`] — tell the UI whether the first-run wizard should show
//! (no `profiles.json` yet) and hand it the reference catalogue.
use std::sync::Arc;
use domain::ports::{AgentRuntime, ProfileStore};
use domain::profile::AgentProfile;
use crate::error::AppError;
use super::catalogue::reference_profiles;
// ---------------------------------------------------------------------------
// DetectProfiles
// ---------------------------------------------------------------------------
/// Input for [`DetectProfiles::execute`]: the candidate profiles to probe.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DetectProfilesInput {
/// Profiles whose `detect` command should be run.
pub candidates: Vec<AgentProfile>,
}
/// Availability of a single candidate after detection.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProfileAvailability {
/// The probed profile.
pub profile: AgentProfile,
/// Whether its CLI was detected as installed (exit code 0).
pub available: bool,
}
/// Output of [`DetectProfiles::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DetectProfilesOutput {
/// One entry per candidate (same order), with its availability.
pub results: Vec<ProfileAvailability>,
}
/// Probes candidate profiles' detection commands and reports availability.
pub struct DetectProfiles {
runtime: Arc<dyn AgentRuntime>,
}
impl DetectProfiles {
/// Builds the use case from the [`AgentRuntime`] port. The runtime itself
/// holds the [`domain::ports::ProcessSpawner`] used for detection.
#[must_use]
pub fn new(runtime: Arc<dyn AgentRuntime>) -> Self {
Self { runtime }
}
/// Runs detection for each candidate. A detection *error* (e.g. the command
/// could not even be launched) is reported as `available: false`, not a
/// hard failure — the wizard just shows ✗ and the user can still keep the
/// profile.
///
/// # Errors
/// Currently never returns `Err` (failures degrade to `available: false`);
/// the `Result` keeps the signature uniform with the other use cases.
pub async fn execute(
&self,
input: DetectProfilesInput,
) -> Result<DetectProfilesOutput, AppError> {
let results = input
.candidates
.into_iter()
.map(|profile| {
let available = self.runtime.detect(&profile).unwrap_or(false);
ProfileAvailability { profile, available }
})
.collect();
Ok(DetectProfilesOutput { results })
}
}
// ---------------------------------------------------------------------------
// ListProfiles
// ---------------------------------------------------------------------------
/// Output of [`ListProfiles::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ListProfilesOutput {
/// All configured profiles.
pub profiles: Vec<AgentProfile>,
}
/// Lists the configured profiles from the store.
pub struct ListProfiles {
store: Arc<dyn ProfileStore>,
}
impl ListProfiles {
/// Builds the use case from the [`ProfileStore`] port.
#[must_use]
pub fn new(store: Arc<dyn ProfileStore>) -> Self {
Self { store }
}
/// Lists configured profiles.
///
/// # Errors
/// [`AppError::Store`] on persistence failure.
pub async fn execute(&self) -> Result<ListProfilesOutput, AppError> {
Ok(ListProfilesOutput {
profiles: self.store.list().await?,
})
}
}
// ---------------------------------------------------------------------------
// SaveProfile
// ---------------------------------------------------------------------------
/// Input for [`SaveProfile::execute`]: the profile to upsert.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SaveProfileInput {
/// The profile to create or replace (by id).
pub profile: AgentProfile,
}
/// Output of [`SaveProfile::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SaveProfileOutput {
/// The saved profile (echoed back).
pub profile: AgentProfile,
}
/// Persists (creates or replaces) a single profile.
pub struct SaveProfile {
store: Arc<dyn ProfileStore>,
}
impl SaveProfile {
/// Builds the use case from the [`ProfileStore`] port.
#[must_use]
pub fn new(store: Arc<dyn ProfileStore>) -> Self {
Self { store }
}
/// Saves the profile.
///
/// # Errors
/// [`AppError::Store`] on persistence failure.
pub async fn execute(&self, input: SaveProfileInput) -> Result<SaveProfileOutput, AppError> {
self.store.save(&input.profile).await?;
Ok(SaveProfileOutput {
profile: input.profile,
})
}
}
// ---------------------------------------------------------------------------
// DeleteProfile
// ---------------------------------------------------------------------------
/// Input for [`DeleteProfile::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DeleteProfileInput {
/// Id of the profile to delete.
pub id: domain::ids::ProfileId,
}
/// Deletes a profile by id.
pub struct DeleteProfile {
store: Arc<dyn ProfileStore>,
}
impl DeleteProfile {
/// Builds the use case from the [`ProfileStore`] port.
#[must_use]
pub fn new(store: Arc<dyn ProfileStore>) -> Self {
Self { store }
}
/// Deletes the profile.
///
/// # Errors
/// [`AppError::NotFound`] if the id is unknown, [`AppError::Store`] on
/// persistence failure.
pub async fn execute(&self, input: DeleteProfileInput) -> Result<(), AppError> {
self.store.delete(input.id).await?;
Ok(())
}
}
// ---------------------------------------------------------------------------
// ConfigureProfiles
// ---------------------------------------------------------------------------
/// Input for [`ConfigureProfiles::execute`]: the chosen/edited/custom profiles.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConfigureProfilesInput {
/// All profiles the user decided to keep (closes the first run).
pub profiles: Vec<AgentProfile>,
}
/// Output of [`ConfigureProfiles::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConfigureProfilesOutput {
/// The persisted profiles.
pub profiles: Vec<AgentProfile>,
}
/// Persists the batch of profiles chosen at the end of the first-run wizard.
///
/// Saving even an empty list creates `profiles.json`, which marks the first run
/// as done (so the wizard does not reappear).
pub struct ConfigureProfiles {
store: Arc<dyn ProfileStore>,
}
impl ConfigureProfiles {
/// Builds the use case from the [`ProfileStore`] port.
#[must_use]
pub fn new(store: Arc<dyn ProfileStore>) -> Self {
Self { store }
}
/// Persists each chosen profile.
///
/// # Errors
/// [`AppError::Store`] on persistence failure.
pub async fn execute(
&self,
input: ConfigureProfilesInput,
) -> Result<ConfigureProfilesOutput, AppError> {
for profile in &input.profiles {
self.store.save(profile).await?;
}
// Ensure `profiles.json` exists even when the user kept nothing, so the
// first run is recorded as complete.
if input.profiles.is_empty() {
self.store.mark_configured().await?;
}
Ok(ConfigureProfilesOutput {
profiles: input.profiles,
})
}
}
// ---------------------------------------------------------------------------
// ReferenceProfiles (catalogue accessor)
// ---------------------------------------------------------------------------
/// Output of [`ReferenceProfiles::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReferenceProfilesOutput {
/// The pre-filled, editable reference catalogue.
pub profiles: Vec<AgentProfile>,
}
/// Exposes the pre-filled reference catalogue (Claude/Codex/Gemini/Aider).
#[derive(Default)]
pub struct ReferenceProfiles;
impl ReferenceProfiles {
/// Builds the (stateless) use case.
#[must_use]
pub fn new() -> Self {
Self
}
/// Returns the reference catalogue. Infallible.
///
/// # Errors
/// Never; the `Result` keeps the call site uniform.
#[allow(clippy::unused_async)]
pub async fn execute(&self) -> Result<ReferenceProfilesOutput, AppError> {
Ok(ReferenceProfilesOutput {
profiles: reference_profiles(),
})
}
}
// ---------------------------------------------------------------------------
// FirstRunState
// ---------------------------------------------------------------------------
/// Output of [`FirstRunState::execute`]: whether to show the wizard + catalogue.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FirstRunStateOutput {
/// `true` when no `profiles.json` exists yet ⇒ show the first-run wizard.
pub is_first_run: bool,
/// The pre-filled reference catalogue to seed the wizard.
pub reference_profiles: Vec<AgentProfile>,
}
/// Reports whether the IDE is on its first run (no profiles configured yet) and
/// provides the reference catalogue to seed the wizard.
pub struct FirstRunState {
store: Arc<dyn ProfileStore>,
}
impl FirstRunState {
/// Builds the use case from the [`ProfileStore`] port.
#[must_use]
pub fn new(store: Arc<dyn ProfileStore>) -> Self {
Self { store }
}
/// Computes the first-run state.
///
/// # Errors
/// [`AppError::Store`] on persistence failure.
pub async fn execute(&self) -> Result<FirstRunStateOutput, AppError> {
let configured = self.store.is_configured().await?;
Ok(FirstRunStateOutput {
is_first_run: !configured,
reference_profiles: reference_profiles(),
})
}
}

View File

@ -0,0 +1,115 @@
//! [`AppError`] — the single error type returned by every use case.
//!
//! Per-port errors from the domain ([`domain::ports::FsError`],
//! [`domain::ports::StoreError`], …) are mapped into this application-level
//! error so that the presentation layer (Tauri commands) only ever has to deal
//! with one error shape when building its `ErrorDTO`.
use domain::ports::{
FsError, GitError, ProcessError, PtyError, RemoteError, RuntimeError, StoreError,
};
/// Errors surfaced by application use cases.
///
/// Each variant carries a stable, machine-readable `code` (see [`AppError::code`])
/// so the presentation layer can map it to an `ErrorDTO` without string matching.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum AppError {
/// A requested resource was not found.
#[error("not found: {0}")]
NotFound(String),
/// The input failed a domain/application invariant.
#[error("invalid input: {0}")]
Invalid(String),
/// A filesystem operation failed.
#[error("filesystem error: {0}")]
FileSystem(String),
/// A persistence (store) operation failed.
#[error("store error: {0}")]
Store(String),
/// A process/PTY/runtime operation failed.
#[error("process error: {0}")]
Process(String),
/// A git operation failed.
#[error("git error: {0}")]
Git(String),
/// A remote (SSH/WSL) operation failed.
#[error("remote error: {0}")]
Remote(String),
/// An unexpected internal error.
#[error("internal error: {0}")]
Internal(String),
}
impl AppError {
/// A stable, machine-readable code for this error, intended for the
/// `ErrorDTO` so the frontend can branch without parsing messages.
#[must_use]
pub fn code(&self) -> &'static str {
match self {
Self::NotFound(_) => "NOT_FOUND",
Self::Invalid(_) => "INVALID",
Self::FileSystem(_) => "FILESYSTEM",
Self::Store(_) => "STORE",
Self::Process(_) => "PROCESS",
Self::Git(_) => "GIT",
Self::Remote(_) => "REMOTE",
Self::Internal(_) => "INTERNAL",
}
}
}
impl From<FsError> for AppError {
fn from(e: FsError) -> Self {
match e {
FsError::NotFound(p) => Self::NotFound(p),
other => Self::FileSystem(other.to_string()),
}
}
}
impl From<StoreError> for AppError {
fn from(e: StoreError) -> Self {
match e {
StoreError::NotFound => Self::NotFound("store item".to_owned()),
other => Self::Store(other.to_string()),
}
}
}
impl From<PtyError> for AppError {
fn from(e: PtyError) -> Self {
Self::Process(e.to_string())
}
}
impl From<ProcessError> for AppError {
fn from(e: ProcessError) -> Self {
Self::Process(e.to_string())
}
}
impl From<RuntimeError> for AppError {
fn from(e: RuntimeError) -> Self {
Self::Process(e.to_string())
}
}
impl From<GitError> for AppError {
fn from(e: GitError) -> Self {
Self::Git(e.to_string())
}
}
impl From<RemoteError> for AppError {
fn from(e: RemoteError) -> Self {
Self::Remote(e.to_string())
}
}

View File

@ -0,0 +1,12 @@
//! Git use cases (ARCHITECTURE §6, L8). Local Git operations orchestrated over
//! the [`domain::ports::GitPort`]: status, staging, commit, branches, checkout
//! and log. State-changing operations announce [`domain::DomainEvent::GitStateChanged`].
mod usecases;
pub use usecases::{
GitBranches, GitBranchesInput, GitBranchesOutput, GitCheckout, GitCheckoutInput, GitCommit,
GitCommitInput, GitCommitOutput, GitGraph, GitGraphInput, GitGraphOutput, GitInit, GitInitInput,
GitLog, GitLogInput, GitLogOutput, GitStage, GitStagePathInput, GitStatus, GitStatusInput,
GitStatusOutput, GitUnstage,
};

View File

@ -0,0 +1,382 @@
//! Git use cases (ARCHITECTURE §6, L8). Thin orchestration over the [`GitPort`]:
//! validate the root, call the port, and (for state-changing operations) announce
//! [`DomainEvent::GitStateChanged`] so the UI can refresh.
use std::sync::Arc;
use domain::ports::{EventBus, GitCommitInfo, GitFileStatus, GitPort, GraphCommit};
use domain::{DomainEvent, ProjectId, ProjectPath};
use crate::error::AppError;
/// Parses a raw root string into a validated [`ProjectPath`].
fn parse_root(root: &str) -> Result<ProjectPath, AppError> {
ProjectPath::new(root).map_err(|e| AppError::Invalid(e.to_string()))
}
// ---------------------------------------------------------------------------
// GitStatus
// ---------------------------------------------------------------------------
/// Input for [`GitStatus::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GitStatusInput {
/// Absolute repository root.
pub root: String,
}
/// Output of [`GitStatus::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GitStatusOutput {
/// Changed paths (staged flag per path).
pub entries: Vec<GitFileStatus>,
}
/// Reports the working-tree status of a repository.
pub struct GitStatus {
git: Arc<dyn GitPort>,
}
impl GitStatus {
/// Builds the use case from the [`GitPort`].
#[must_use]
pub fn new(git: Arc<dyn GitPort>) -> Self {
Self { git }
}
/// Returns the status.
///
/// # Errors
/// - [`AppError::Invalid`] for a non-absolute root,
/// - [`AppError::Git`] if the repo is missing or the operation fails.
pub async fn execute(&self, input: GitStatusInput) -> Result<GitStatusOutput, AppError> {
let root = parse_root(&input.root)?;
let entries = self.git.status(&root).await?;
Ok(GitStatusOutput { entries })
}
}
// ---------------------------------------------------------------------------
// GitStage / GitUnstage
// ---------------------------------------------------------------------------
/// Input for [`GitStage::execute`] / [`GitUnstage::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GitStagePathInput {
/// Absolute repository root.
pub root: String,
/// Repo-relative path to (un)stage.
pub path: String,
}
/// Stages a path (adds it to the index).
pub struct GitStage {
git: Arc<dyn GitPort>,
}
impl GitStage {
/// Builds the use case.
#[must_use]
pub fn new(git: Arc<dyn GitPort>) -> Self {
Self { git }
}
/// Stages the path.
///
/// # Errors
/// [`AppError::Invalid`] for a bad root, [`AppError::Git`] on failure.
pub async fn execute(&self, input: GitStagePathInput) -> Result<(), AppError> {
let root = parse_root(&input.root)?;
self.git.stage(&root, &input.path).await?;
Ok(())
}
}
/// Unstages a path (resets it to its HEAD state, or removes it when unborn).
pub struct GitUnstage {
git: Arc<dyn GitPort>,
}
impl GitUnstage {
/// Builds the use case.
#[must_use]
pub fn new(git: Arc<dyn GitPort>) -> Self {
Self { git }
}
/// Unstages the path.
///
/// # Errors
/// [`AppError::Invalid`] for a bad root, [`AppError::Git`] on failure.
pub async fn execute(&self, input: GitStagePathInput) -> Result<(), AppError> {
let root = parse_root(&input.root)?;
self.git.unstage(&root, &input.path).await?;
Ok(())
}
}
// ---------------------------------------------------------------------------
// GitCommit
// ---------------------------------------------------------------------------
/// Input for [`GitCommit::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GitCommitInput {
/// The project (for the emitted event).
pub project_id: ProjectId,
/// Absolute repository root.
pub root: String,
/// Commit message.
pub message: String,
}
/// Output of [`GitCommit::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GitCommitOutput {
/// The created commit.
pub commit: GitCommitInfo,
}
/// Commits the staged index, announcing [`DomainEvent::GitStateChanged`].
pub struct GitCommit {
git: Arc<dyn GitPort>,
events: Arc<dyn EventBus>,
}
impl GitCommit {
/// Builds the use case from its ports.
#[must_use]
pub fn new(git: Arc<dyn GitPort>, events: Arc<dyn EventBus>) -> Self {
Self { git, events }
}
/// Creates the commit.
///
/// # Errors
/// - [`AppError::Invalid`] for a bad root or an empty message,
/// - [`AppError::Git`] on failure.
pub async fn execute(&self, input: GitCommitInput) -> Result<GitCommitOutput, AppError> {
if input.message.trim().is_empty() {
return Err(AppError::Invalid("commit message is empty".to_owned()));
}
let root = parse_root(&input.root)?;
let commit = self.git.commit(&root, &input.message).await?;
self.events.publish(DomainEvent::GitStateChanged {
project_id: input.project_id,
});
Ok(GitCommitOutput { commit })
}
}
// ---------------------------------------------------------------------------
// GitBranches
// ---------------------------------------------------------------------------
/// Input for [`GitBranches::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GitBranchesInput {
/// Absolute repository root.
pub root: String,
}
/// Output of [`GitBranches::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GitBranchesOutput {
/// All local branches.
pub branches: Vec<String>,
/// The current branch (`None` when detached or unborn).
pub current: Option<String>,
}
/// Lists local branches and the current one.
pub struct GitBranches {
git: Arc<dyn GitPort>,
}
impl GitBranches {
/// Builds the use case.
#[must_use]
pub fn new(git: Arc<dyn GitPort>) -> Self {
Self { git }
}
/// Lists branches + current.
///
/// # Errors
/// [`AppError::Invalid`] for a bad root, [`AppError::Git`] on failure.
pub async fn execute(&self, input: GitBranchesInput) -> Result<GitBranchesOutput, AppError> {
let root = parse_root(&input.root)?;
let branches = self.git.branches(&root).await?;
let current = self.git.current_branch(&root).await?;
Ok(GitBranchesOutput { branches, current })
}
}
// ---------------------------------------------------------------------------
// GitCheckout
// ---------------------------------------------------------------------------
/// Input for [`GitCheckout::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GitCheckoutInput {
/// The project (for the emitted event).
pub project_id: ProjectId,
/// Absolute repository root.
pub root: String,
/// Branch to check out.
pub branch: String,
}
/// Checks out a branch, announcing [`DomainEvent::GitStateChanged`].
pub struct GitCheckout {
git: Arc<dyn GitPort>,
events: Arc<dyn EventBus>,
}
impl GitCheckout {
/// Builds the use case from its ports.
#[must_use]
pub fn new(git: Arc<dyn GitPort>, events: Arc<dyn EventBus>) -> Self {
Self { git, events }
}
/// Checks out the branch.
///
/// # Errors
/// [`AppError::Invalid`] for a bad root, [`AppError::Git`] on failure.
pub async fn execute(&self, input: GitCheckoutInput) -> Result<(), AppError> {
let root = parse_root(&input.root)?;
self.git.checkout(&root, &input.branch).await?;
self.events.publish(DomainEvent::GitStateChanged {
project_id: input.project_id,
});
Ok(())
}
}
// ---------------------------------------------------------------------------
// GitLog
// ---------------------------------------------------------------------------
/// Input for [`GitLog::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GitLogInput {
/// Absolute repository root.
pub root: String,
/// Maximum number of commits to return.
pub limit: usize,
}
/// Output of [`GitLog::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GitLogOutput {
/// Recent commits, newest first.
pub commits: Vec<GitCommitInfo>,
}
/// Returns the recent commit log.
pub struct GitLog {
git: Arc<dyn GitPort>,
}
impl GitLog {
/// Builds the use case.
#[must_use]
pub fn new(git: Arc<dyn GitPort>) -> Self {
Self { git }
}
/// Returns the log.
///
/// # Errors
/// [`AppError::Invalid`] for a bad root, [`AppError::Git`] on failure.
pub async fn execute(&self, input: GitLogInput) -> Result<GitLogOutput, AppError> {
let root = parse_root(&input.root)?;
let commits = self.git.log(&root, input.limit).await?;
Ok(GitLogOutput { commits })
}
}
// ---------------------------------------------------------------------------
// GitInit
// ---------------------------------------------------------------------------
/// Input for [`GitInit::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GitInitInput {
/// The project (for the emitted event).
pub project_id: ProjectId,
/// Absolute repository root.
pub root: String,
}
/// Initialises a repository at the project root, announcing
/// [`DomainEvent::GitStateChanged`].
pub struct GitInit {
git: Arc<dyn GitPort>,
events: Arc<dyn EventBus>,
}
impl GitInit {
/// Builds the use case from its ports.
#[must_use]
pub fn new(git: Arc<dyn GitPort>, events: Arc<dyn EventBus>) -> Self {
Self { git, events }
}
/// Initialises the repository.
///
/// # Errors
/// [`AppError::Invalid`] for a bad root, [`AppError::Git`] on failure.
pub async fn execute(&self, input: GitInitInput) -> Result<(), AppError> {
let root = parse_root(&input.root)?;
self.git.init(&root).await?;
self.events.publish(DomainEvent::GitStateChanged {
project_id: input.project_id,
});
Ok(())
}
}
// ---------------------------------------------------------------------------
// GitGraph
// ---------------------------------------------------------------------------
/// Input for [`GitGraph::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GitGraphInput {
/// Absolute repository root.
pub root: String,
/// Maximum number of commits to return.
pub limit: usize,
}
/// Output of [`GitGraph::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GitGraphOutput {
/// Graph commits (topological + time order), newest first.
pub commits: Vec<GraphCommit>,
}
/// Returns the full commit graph for all local branches.
pub struct GitGraph {
git: Arc<dyn GitPort>,
}
impl GitGraph {
/// Builds the use case.
#[must_use]
pub fn new(git: Arc<dyn GitPort>) -> Self {
Self { git }
}
/// Returns the graph.
///
/// # Errors
/// [`AppError::Invalid`] for a bad root, [`AppError::Git`] on failure.
pub async fn execute(&self, input: GitGraphInput) -> Result<GitGraphOutput, AppError> {
let root = parse_root(&input.root)?;
let commits = self.git.log_graph(&root, input.limit).await?;
Ok(GitGraphOutput { commits })
}
}

View File

@ -0,0 +1,84 @@
//! [`HealthUseCase`] — a trivial, in-memory use case used to validate the
//! end-to-end wiring (composition root → Tauri command → use case → ports →
//! frontend gateway). It carries its ports as `Arc<dyn Port>`, exactly like
//! every real use case will (ARCHITECTURE §6).
//!
//! It depends only on the utility ports [`Clock`] and [`IdGenerator`], so it
//! exercises dependency injection without needing any I/O adapter.
use std::sync::Arc;
use domain::ports::{Clock, EventBus, IdGenerator};
use domain::DomainEvent;
use domain::ProjectId;
use crate::error::AppError;
/// Input for [`HealthUseCase::execute`].
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct HealthInput {
/// Optional caller-supplied note echoed back in the report (used by tests
/// and the frontend smoke check).
pub note: Option<String>,
}
/// Output of [`HealthUseCase::execute`]: a small health report.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HealthReport {
/// Application/crate version (`CARGO_PKG_VERSION`).
pub version: String,
/// Liveness flag — always `true` if the use case ran.
pub alive: bool,
/// Server "now" in epoch milliseconds (from the injected [`Clock`]).
pub time_millis: i64,
/// A fresh correlation id (from the injected [`IdGenerator`]).
pub correlation_id: String,
/// Echoed caller note, if any.
pub note: Option<String>,
}
/// Trivial health/ping use case validating the DI + IPC pipeline.
pub struct HealthUseCase {
clock: Arc<dyn Clock>,
ids: Arc<dyn IdGenerator>,
events: Arc<dyn EventBus>,
}
impl HealthUseCase {
/// Builds the use case from its injected ports.
#[must_use]
pub fn new(clock: Arc<dyn Clock>, ids: Arc<dyn IdGenerator>, events: Arc<dyn EventBus>) -> Self {
Self {
clock,
ids,
events,
}
}
/// Executes the health check.
///
/// As a side effect it publishes a [`DomainEvent`] on the [`EventBus`] so
/// the event-relay path is exercised end to end (the relay forwards it to a
/// Tauri event). A `ProjectCreated`-shaped event is reused here purely as a
/// no-op smoke signal; no project is actually created.
///
/// # Errors
/// Returns [`AppError`] — never, in this trivial implementation, but the
/// signature matches the real use-case contract.
pub fn execute(&self, input: HealthInput) -> Result<HealthReport, AppError> {
let correlation = self.ids.new_uuid();
// Exercise the event-bus relay path with a harmless smoke event.
self.events.publish(DomainEvent::ProjectCreated {
project_id: ProjectId::from_uuid(correlation),
});
Ok(HealthReport {
version: env!("CARGO_PKG_VERSION").to_owned(),
alive: true,
time_millis: self.clock.now_millis(),
correlation_id: correlation.to_string(),
note: input.note,
})
}
}

View File

@ -0,0 +1,336 @@
//! Named-layout management use cases (#4): list, create, rename, delete and set
//! the active layout. Each loads the project's layouts store (see
//! [`super::store`]), applies the change and persists it.
use std::sync::Arc;
use domain::ports::{EventBus, FileSystem, IdGenerator, ProjectStore};
use domain::{DomainEvent, LayoutId, ProjectId};
use crate::error::AppError;
use super::store::{default_tree, persist_doc, resolve_doc, LayoutKind, NamedLayout};
/// Lightweight descriptor of a named layout (no tree), for the layouts tab bar.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LayoutInfo {
/// Stable identifier.
pub id: LayoutId,
/// Display name.
pub name: String,
/// Kind of this layout.
pub kind: LayoutKind,
}
// ---------------------------------------------------------------------------
// ListLayouts
// ---------------------------------------------------------------------------
/// Input for [`ListLayouts::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ListLayoutsInput {
/// Project whose layouts to list.
pub project_id: ProjectId,
}
/// Output of [`ListLayouts::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ListLayoutsOutput {
/// All named layouts (id + name), in order.
pub layouts: Vec<LayoutInfo>,
/// The active layout.
pub active_id: LayoutId,
}
/// Lists a project's named layouts and the active one.
pub struct ListLayouts {
store: Arc<dyn ProjectStore>,
fs: Arc<dyn FileSystem>,
}
impl ListLayouts {
/// Builds the use case from its ports.
#[must_use]
pub fn new(store: Arc<dyn ProjectStore>, fs: Arc<dyn FileSystem>) -> Self {
Self { store, fs }
}
/// Lists the layouts.
///
/// # Errors
/// [`AppError::NotFound`] for an unknown project, [`AppError::FileSystem`] /
/// [`AppError::Store`] on I/O failure.
pub async fn execute(&self, input: ListLayoutsInput) -> Result<ListLayoutsOutput, AppError> {
let project = self.store.load_project(input.project_id).await?;
let doc = resolve_doc(self.fs.as_ref(), &project).await?;
Ok(ListLayoutsOutput {
layouts: doc
.layouts
.iter()
.map(|l| LayoutInfo {
id: l.id,
name: l.name.clone(),
kind: l.kind,
})
.collect(),
active_id: doc.active_id,
})
}
}
// ---------------------------------------------------------------------------
// CreateLayout
// ---------------------------------------------------------------------------
/// Input for [`CreateLayout::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CreateLayoutInput {
/// Owning project.
pub project_id: ProjectId,
/// Display name for the new layout.
pub name: String,
/// Kind of the new layout (defaults to Terminal).
pub kind: LayoutKind,
}
/// Output of [`CreateLayout::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CreateLayoutOutput {
/// The id minted for the new (now active) layout.
pub layout_id: LayoutId,
}
/// Creates a new empty named layout and makes it active.
pub struct CreateLayout {
store: Arc<dyn ProjectStore>,
fs: Arc<dyn FileSystem>,
ids: Arc<dyn IdGenerator>,
events: Arc<dyn EventBus>,
}
impl CreateLayout {
/// Builds the use case from its ports.
#[must_use]
pub fn new(
store: Arc<dyn ProjectStore>,
fs: Arc<dyn FileSystem>,
ids: Arc<dyn IdGenerator>,
events: Arc<dyn EventBus>,
) -> Self {
Self {
store,
fs,
ids,
events,
}
}
/// Creates the layout.
///
/// # Errors
/// [`AppError::Invalid`] for an empty name, [`AppError::NotFound`] for an
/// unknown project, I/O errors otherwise.
pub async fn execute(&self, input: CreateLayoutInput) -> Result<CreateLayoutOutput, AppError> {
let name = input.name.trim();
if name.is_empty() {
return Err(AppError::Invalid("layout name is empty".to_owned()));
}
let project = self.store.load_project(input.project_id).await?;
let mut doc = resolve_doc(self.fs.as_ref(), &project).await?;
let id = LayoutId::from_uuid(self.ids.new_uuid());
doc.layouts.push(NamedLayout {
id,
name: name.to_owned(),
kind: input.kind,
tree: default_tree(),
});
doc.active_id = id; // a freshly-created layout becomes active.
persist_doc(self.fs.as_ref(), &project, &doc).await?;
self.events.publish(DomainEvent::LayoutChanged {
project_id: input.project_id,
});
Ok(CreateLayoutOutput { layout_id: id })
}
}
// ---------------------------------------------------------------------------
// RenameLayout
// ---------------------------------------------------------------------------
/// Input for [`RenameLayout::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RenameLayoutInput {
/// Owning project.
pub project_id: ProjectId,
/// Layout to rename.
pub layout_id: LayoutId,
/// New display name.
pub name: String,
}
/// Renames a named layout.
pub struct RenameLayout {
store: Arc<dyn ProjectStore>,
fs: Arc<dyn FileSystem>,
events: Arc<dyn EventBus>,
}
impl RenameLayout {
/// Builds the use case from its ports.
#[must_use]
pub fn new(
store: Arc<dyn ProjectStore>,
fs: Arc<dyn FileSystem>,
events: Arc<dyn EventBus>,
) -> Self {
Self { store, fs, events }
}
/// Renames the layout.
///
/// # Errors
/// [`AppError::Invalid`] for an empty name, [`AppError::NotFound`] if the
/// project or layout is unknown.
pub async fn execute(&self, input: RenameLayoutInput) -> Result<(), AppError> {
let name = input.name.trim();
if name.is_empty() {
return Err(AppError::Invalid("layout name is empty".to_owned()));
}
let project = self.store.load_project(input.project_id).await?;
let mut doc = resolve_doc(self.fs.as_ref(), &project).await?;
let named = doc
.find_mut(input.layout_id)
.ok_or_else(|| AppError::NotFound(format!("layout {}", input.layout_id)))?;
named.name = name.to_owned();
persist_doc(self.fs.as_ref(), &project, &doc).await?;
self.events.publish(DomainEvent::LayoutChanged {
project_id: input.project_id,
});
Ok(())
}
}
// ---------------------------------------------------------------------------
// DeleteLayout
// ---------------------------------------------------------------------------
/// Input for [`DeleteLayout::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DeleteLayoutInput {
/// Owning project.
pub project_id: ProjectId,
/// Layout to delete.
pub layout_id: LayoutId,
}
/// Output of [`DeleteLayout::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DeleteLayoutOutput {
/// The active layout after the deletion.
pub active_id: LayoutId,
}
/// Deletes a named layout. The last remaining layout cannot be deleted; if the
/// active layout is removed, the first remaining one becomes active.
pub struct DeleteLayout {
store: Arc<dyn ProjectStore>,
fs: Arc<dyn FileSystem>,
events: Arc<dyn EventBus>,
}
impl DeleteLayout {
/// Builds the use case from its ports.
#[must_use]
pub fn new(
store: Arc<dyn ProjectStore>,
fs: Arc<dyn FileSystem>,
events: Arc<dyn EventBus>,
) -> Self {
Self { store, fs, events }
}
/// Deletes the layout.
///
/// # Errors
/// [`AppError::Invalid`] if it is the last layout, [`AppError::NotFound`] if
/// the project or layout is unknown.
pub async fn execute(&self, input: DeleteLayoutInput) -> Result<DeleteLayoutOutput, AppError> {
let project = self.store.load_project(input.project_id).await?;
let mut doc = resolve_doc(self.fs.as_ref(), &project).await?;
if doc.layouts.len() <= 1 {
return Err(AppError::Invalid(
"cannot delete the last layout".to_owned(),
));
}
if doc.find(input.layout_id).is_none() {
return Err(AppError::NotFound(format!("layout {}", input.layout_id)));
}
doc.layouts.retain(|l| l.id != input.layout_id);
if doc.active_id == input.layout_id {
doc.active_id = doc.layouts[0].id;
}
persist_doc(self.fs.as_ref(), &project, &doc).await?;
self.events.publish(DomainEvent::LayoutChanged {
project_id: input.project_id,
});
Ok(DeleteLayoutOutput {
active_id: doc.active_id,
})
}
}
// ---------------------------------------------------------------------------
// SetActiveLayout
// ---------------------------------------------------------------------------
/// Input for [`SetActiveLayout::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SetActiveLayoutInput {
/// Owning project.
pub project_id: ProjectId,
/// Layout to make active.
pub layout_id: LayoutId,
}
/// Switches the active layout of a project.
pub struct SetActiveLayout {
store: Arc<dyn ProjectStore>,
fs: Arc<dyn FileSystem>,
events: Arc<dyn EventBus>,
}
impl SetActiveLayout {
/// Builds the use case from its ports.
#[must_use]
pub fn new(
store: Arc<dyn ProjectStore>,
fs: Arc<dyn FileSystem>,
events: Arc<dyn EventBus>,
) -> Self {
Self { store, fs, events }
}
/// Sets the active layout.
///
/// # Errors
/// [`AppError::NotFound`] if the project or layout is unknown.
pub async fn execute(&self, input: SetActiveLayoutInput) -> Result<(), AppError> {
let project = self.store.load_project(input.project_id).await?;
let mut doc = resolve_doc(self.fs.as_ref(), &project).await?;
if doc.find(input.layout_id).is_none() {
return Err(AppError::NotFound(format!("layout {}", input.layout_id)));
}
doc.active_id = input.layout_id;
persist_doc(self.fs.as_ref(), &project, &doc).await?;
self.events.publish(DomainEvent::LayoutChanged {
project_id: input.project_id,
});
Ok(())
}
}

View File

@ -0,0 +1,36 @@
//! Layout use cases (ARCHITECTURE §6, §7, L4).
//!
//! The terminal layout of a tab is a pure [`domain::LayoutTree`] (a recursive
//! spreadsheet-like grid). Its mutating operations (`split`/`merge`/`resize`/
//! `move`/`set_session`) are **pure functions** that live in the domain; this
//! module is the thin application orchestration that:
//!
//! - resolves the project root (via the [`domain::ports::ProjectStore`]),
//! - reads/writes the tree from/to `.ideai/layout.json` (via the
//! [`domain::ports::FileSystem`] port — so the layout *travels with the
//! project*, including on remote hosts, ARCHITECTURE §7.3, §9.1),
//! - publishes [`domain::DomainEvent::LayoutChanged`] on every mutation.
//!
//! ## Layout ↔ terminal session binding (L3 ↔ L4)
//!
//! A [`domain::LeafCell`] carries `Option<SessionId>`. When the UI opens a
//! terminal in a cell, it calls `OpenTerminal` (L3) — which mints the
//! `SessionId` — then records that id in the hosting leaf through the
//! `SetSession` layout operation here. The layout is the single source of truth
//! for *which cell hosts which session*; the live PTY handle stays in L3's
//! `TerminalSessions` registry, keyed by that same `SessionId`.
mod management;
mod store;
mod usecases;
pub use management::{
CreateLayout, CreateLayoutInput, CreateLayoutOutput, DeleteLayout, DeleteLayoutInput,
DeleteLayoutOutput, LayoutInfo, ListLayouts, ListLayoutsInput, ListLayoutsOutput, RenameLayout,
RenameLayoutInput, SetActiveLayout, SetActiveLayoutInput,
};
pub use store::{LayoutKind, LayoutsDoc, NamedLayout, LAYOUTS_FILE};
pub use usecases::{
LayoutOperation, LoadLayout, LoadLayoutInput, LoadLayoutOutput, MutateLayout,
MutateLayoutInput, MutateLayoutOutput,
};

View File

@ -0,0 +1,182 @@
//! Persistence of a project's **named terminal layouts** (#4).
//!
//! A project no longer has a single layout: `.ideai/layouts.json` holds a
//! collection of named [`LayoutTree`]s plus which one is active:
//!
//! ```json
//! { "version": 1, "activeId": "…", "layouts": [ { "id": "…", "name": "Default", "tree": { … } } ] }
//! ```
//!
//! Migration: a project that still has the legacy single `.ideai/layout.json`
//! (pre-#4) is upgraded transparently — its tree becomes the first "Default"
//! layout. A missing/corrupt store self-heals to one default layout (same
//! self-healing contract as the original L4 single-file resolver).
use serde::{Deserialize, Serialize};
use domain::ports::{FileSystem, RemotePath};
use domain::{LayoutId, LayoutTree, LeafCell, NodeId, Project};
use crate::error::AppError;
use crate::project::meta::{from_json_bytes, join_root, to_json_bytes, IDEAI_DIR};
/// File name of the named-layouts store inside a project's `.ideai/`.
pub const LAYOUTS_FILE: &str = "layouts.json";
/// Legacy single-layout file (pre-#4), migrated on first read if present.
const LEGACY_LAYOUT_FILE: &str = "layout.json";
/// Current schema version of `layouts.json`.
const LAYOUTS_VERSION: u32 = 1;
/// Name given to the layout created by default / migrated from the legacy file.
const DEFAULT_LAYOUT_NAME: &str = "Default";
/// Discriminates the kind of content a named layout holds.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum LayoutKind {
/// A terminal-grid layout (the original kind).
#[default]
Terminal,
/// A Git-graph visualisation layout.
GitGraph,
}
/// One named layout: a stable id, a display name and its terminal grid tree.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct NamedLayout {
/// Stable identifier.
pub id: LayoutId,
/// Display name (shown in the layouts tab bar).
pub name: String,
/// The kind of this layout (terminal grid or git-graph).
#[serde(default)]
pub kind: LayoutKind,
/// The terminal grid for this layout (present but ignored for GitGraph).
pub tree: LayoutTree,
}
/// On-disk shape of `.ideai/layouts.json`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LayoutsDoc {
/// Schema version.
pub version: u32,
/// The currently active layout (always one of `layouts`).
pub active_id: LayoutId,
/// All named layouts (at least one).
pub layouts: Vec<NamedLayout>,
}
impl LayoutsDoc {
/// The active layout id, or the explicit one when provided.
#[must_use]
pub fn resolve_id(&self, id: Option<LayoutId>) -> LayoutId {
id.unwrap_or(self.active_id)
}
/// Finds a layout by id.
#[must_use]
pub fn find(&self, id: LayoutId) -> Option<&NamedLayout> {
self.layouts.iter().find(|l| l.id == id)
}
/// Mutable access to a layout by id.
pub fn find_mut(&mut self, id: LayoutId) -> Option<&mut NamedLayout> {
self.layouts.iter_mut().find(|l| l.id == id)
}
/// Structural validity: non-empty, `active_id` present, every tree valid.
fn is_valid(&self) -> bool {
!self.layouts.is_empty()
&& self.find(self.active_id).is_some()
&& self.layouts.iter().all(|l| l.tree.validate().is_ok())
}
}
/// The default single-cell layout tree (one empty leaf).
#[must_use]
pub fn default_tree() -> LayoutTree {
LayoutTree::single(LeafCell {
id: NodeId::new_random(),
session: None,
agent: None,
})
}
/// Builds a fresh doc holding one layout (`tree`) made active.
fn doc_with(id: LayoutId, name: &str, tree: LayoutTree) -> LayoutsDoc {
LayoutsDoc {
version: LAYOUTS_VERSION,
active_id: id,
layouts: vec![NamedLayout {
id,
name: name.to_owned(),
kind: LayoutKind::Terminal,
tree,
}],
}
}
fn layouts_path(project: &Project) -> RemotePath {
RemotePath::new(join_root(
&project.root,
&format!("{IDEAI_DIR}/{LAYOUTS_FILE}"),
))
}
fn legacy_path(project: &Project) -> RemotePath {
RemotePath::new(join_root(
&project.root,
&format!("{IDEAI_DIR}/{LEGACY_LAYOUT_FILE}"),
))
}
/// Reads the legacy single layout tree if present and valid (for migration).
async fn read_legacy_tree(fs: &dyn FileSystem, project: &Project) -> Option<LayoutTree> {
let bytes = fs.read(&legacy_path(project)).await.ok()?;
let tree = from_json_bytes::<LayoutTree>(&bytes).ok()?;
tree.validate().is_ok().then_some(tree)
}
/// Writes the doc to `.ideai/layouts.json` (creating `.ideai/` if needed).
pub async fn persist_doc(
fs: &dyn FileSystem,
project: &Project,
doc: &LayoutsDoc,
) -> Result<(), AppError> {
let ideai_dir = RemotePath::new(join_root(&project.root, IDEAI_DIR));
fs.create_dir_all(&ideai_dir).await?;
fs.write(&layouts_path(project), &to_json_bytes(doc)?).await?;
Ok(())
}
/// Resolves the project's layouts doc, with idempotent initialisation /
/// migration / self-healing (mirrors the L4 single-file resolver contract).
///
/// - **Present & valid**: returned as-is (no write).
/// - **Absent**: migrated from the legacy `layout.json` if present, else seeded
/// with one empty "Default" layout — and **persisted** (write-through, so ids
/// are stable from the first load).
/// - **Present but corrupt/invalid**: overwritten with a fresh default.
pub async fn resolve_doc(fs: &dyn FileSystem, project: &Project) -> Result<LayoutsDoc, AppError> {
if let Ok(bytes) = fs.read(&layouts_path(project)).await {
if let Ok(doc) = from_json_bytes::<LayoutsDoc>(&bytes) {
if doc.is_valid() {
return Ok(doc);
}
}
// Present-but-invalid: fall through and re-seed.
}
// First run for this project (or self-heal): migrate the legacy tree if any.
let tree = match read_legacy_tree(fs, project).await {
Some(tree) => tree,
None => default_tree(),
};
let doc = doc_with(LayoutId::new_random(), DEFAULT_LAYOUT_NAME, tree);
persist_doc(fs, project, &doc).await?;
Ok(doc)
}

View File

@ -0,0 +1,232 @@
//! [`LoadLayout`] and [`MutateLayout`] (ARCHITECTURE §6, §7; L4 + #4).
//!
//! A project owns **several named layouts** (see [`super::store`]). These two use
//! cases operate on **one** layout — the one identified by `layout_id`, or the
//! active layout when `layout_id` is `None`. They stay thin orchestrators: the
//! mutating operations are the domain's pure `LayoutTree` functions.
use std::sync::Arc;
use domain::ports::{EventBus, FileSystem, ProjectStore};
use domain::{AgentId, Direction, DomainEvent, LayoutError, LayoutId, LayoutTree, LeafCell, NodeId, ProjectId, SessionId};
use crate::error::AppError;
use super::store::{persist_doc, resolve_doc};
/// Maps a [`LayoutError`] to the application error type.
fn map_layout_err(e: LayoutError) -> AppError {
match e {
LayoutError::NodeNotFound(id) => AppError::NotFound(format!("layout node {id}")),
other => AppError::Invalid(other.to_string()),
}
}
/// A layout mutation expressed in terms of the pure domain operations.
///
/// Each variant maps 1:1 to a pure `LayoutTree` method
/// (`split`/`merge`/`resize`/`move`/`set_session`). Decoupling the *operation*
/// from the *tree* keeps the use case a thin orchestrator and lets the
/// presentation layer (and undo/redo) speak in intentions.
#[derive(Debug, Clone, PartialEq)]
pub enum LayoutOperation {
/// Split a leaf into a two-child split (original + a new empty leaf).
Split {
/// The leaf to split.
target: NodeId,
/// Row (columns) or Column (rows).
direction: Direction,
/// Id for the new sibling leaf.
new_leaf: NodeId,
/// Id for the wrapping split container.
container: NodeId,
},
/// Collapse a split container back to one of its children.
Merge {
/// The split container to collapse.
container: NodeId,
/// Index of the child to keep.
keep_index: usize,
},
/// Reassign the relative weights of a split's children.
Resize {
/// The split container to resize.
container: NodeId,
/// New weights (one per child, all `> 0`).
weights: Vec<f32>,
},
/// Move the session hosted by one leaf to another (empty) leaf.
Move {
/// Source leaf (left empty).
from: NodeId,
/// Target leaf (must be empty).
to: NodeId,
},
/// Attach or detach a session to/from a leaf (cell ↔ terminal binding).
SetSession {
/// The hosting leaf.
target: NodeId,
/// Session to host, or `None` to clear.
session: Option<SessionId>,
},
/// Attach or detach an agent to/from a leaf (per-cell agent, feature #3).
SetCellAgent {
/// The hosting leaf.
target: NodeId,
/// Agent to associate, or `None` to clear.
agent: Option<AgentId>,
},
}
impl LayoutOperation {
/// Applies this operation to `tree`, returning the new validated tree.
fn apply(&self, tree: &LayoutTree) -> Result<LayoutTree, AppError> {
let result = match self {
Self::Split {
target,
direction,
new_leaf,
container,
} => tree.split(
*target,
*direction,
LeafCell {
id: *new_leaf,
session: None,
agent: None,
},
*container,
),
Self::Merge {
container,
keep_index,
} => tree.merge(*container, *keep_index),
Self::Resize { container, weights } => tree.resize(*container, weights),
Self::Move { from, to } => tree.move_session(*from, *to),
Self::SetSession { target, session } => tree.set_session(*target, *session),
Self::SetCellAgent { target, agent } => tree.set_cell_agent(*target, *agent),
};
result.map_err(map_layout_err)
}
}
/// Input for [`LoadLayout::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LoadLayoutInput {
/// Project whose layout to load.
pub project_id: ProjectId,
/// Which named layout to load; `None` loads the active one.
pub layout_id: Option<LayoutId>,
}
/// Output of [`LoadLayout::execute`].
#[derive(Debug, Clone, PartialEq)]
pub struct LoadLayoutOutput {
/// The id of the layout that was loaded (resolved from active when omitted).
pub layout_id: LayoutId,
/// The loaded layout tree.
pub layout: LayoutTree,
}
/// Loads one named layout (the active one by default), self-healing / migrating
/// the layouts store as needed (see [`super::store::resolve_doc`]).
pub struct LoadLayout {
store: Arc<dyn ProjectStore>,
fs: Arc<dyn FileSystem>,
}
impl LoadLayout {
/// Builds the use case from its injected ports.
#[must_use]
pub fn new(store: Arc<dyn ProjectStore>, fs: Arc<dyn FileSystem>) -> Self {
Self { store, fs }
}
/// Executes the load.
///
/// # Errors
/// - [`AppError::NotFound`] if the project or the requested layout is unknown,
/// - [`AppError::FileSystem`] if seeding the default layouts fails to persist,
/// - [`AppError::Store`] on registry I/O failure.
pub async fn execute(&self, input: LoadLayoutInput) -> Result<LoadLayoutOutput, AppError> {
let project = self.store.load_project(input.project_id).await?;
let doc = resolve_doc(self.fs.as_ref(), &project).await?;
let id = doc.resolve_id(input.layout_id);
let named = doc
.find(id)
.ok_or_else(|| AppError::NotFound(format!("layout {id}")))?;
Ok(LoadLayoutOutput {
layout_id: id,
layout: named.tree.clone(),
})
}
}
/// Input for [`MutateLayout::execute`].
#[derive(Debug, Clone, PartialEq)]
pub struct MutateLayoutInput {
/// Project whose layout to mutate.
pub project_id: ProjectId,
/// Which named layout to mutate; `None` mutates the active one.
pub layout_id: Option<LayoutId>,
/// The operation to apply.
pub operation: LayoutOperation,
}
/// Output of [`MutateLayout::execute`].
#[derive(Debug, Clone, PartialEq)]
pub struct MutateLayoutOutput {
/// The id of the mutated layout.
pub layout_id: LayoutId,
/// The new, persisted layout tree.
pub layout: LayoutTree,
}
/// Applies a pure layout operation to one named layout, persists the whole
/// layouts store and publishes [`DomainEvent::LayoutChanged`].
pub struct MutateLayout {
store: Arc<dyn ProjectStore>,
fs: Arc<dyn FileSystem>,
events: Arc<dyn EventBus>,
}
impl MutateLayout {
/// Builds the use case from its injected ports.
#[must_use]
pub fn new(
store: Arc<dyn ProjectStore>,
fs: Arc<dyn FileSystem>,
events: Arc<dyn EventBus>,
) -> Self {
Self { store, fs, events }
}
/// Executes the mutation.
///
/// # Errors
/// - [`AppError::NotFound`] if the project, layout or a referenced node is unknown,
/// - [`AppError::Invalid`] if the operation violates a layout invariant,
/// - [`AppError::FileSystem`] on persistence failure,
/// - [`AppError::Store`] on registry I/O failure.
pub async fn execute(&self, input: MutateLayoutInput) -> Result<MutateLayoutOutput, AppError> {
let project = self.store.load_project(input.project_id).await?;
let mut doc = resolve_doc(self.fs.as_ref(), &project).await?;
let id = doc.resolve_id(input.layout_id);
let named = doc
.find_mut(id)
.ok_or_else(|| AppError::NotFound(format!("layout {id}")))?;
let next = input.operation.apply(&named.tree)?;
named.tree = next.clone();
persist_doc(self.fs.as_ref(), &project, &doc).await?;
self.events.publish(DomainEvent::LayoutChanged {
project_id: input.project_id,
});
Ok(MutateLayoutOutput {
layout_id: id,
layout: next,
})
}
}

View File

@ -0,0 +1,69 @@
//! # IdeA — Application layer
//!
//! Orchestrates **use cases** by talking **only to the domain ports** (traits),
//! never to concrete adapters (ARCHITECTURE §1.1, §6). Every use case is a
//! struct carrying its ports as `Arc<dyn Port>` and exposing
//! `execute(input) -> Result<output, AppError>`.
//!
//! This crate depends on `domain` only. The composition root (`app-tauri`) is
//! the single place that constructs concrete adapters and injects them here.
#![forbid(unsafe_code)]
#![warn(missing_docs)]
pub mod agent;
pub mod error;
pub mod git;
pub mod health;
pub mod layout;
pub mod project;
pub mod remote;
pub mod template;
pub mod terminal;
pub mod window;
pub use agent::{
reference_profile_id, reference_profiles, ConfigureProfiles, ConfigureProfilesInput,
ConfigureProfilesOutput, CreateAgentFromScratch, CreateAgentInput, CreateAgentOutput,
DeleteAgent, DeleteAgentInput, DeleteProfile, DeleteProfileInput, DetectProfiles,
DetectProfilesInput, DetectProfilesOutput, FirstRunState, FirstRunStateOutput, LaunchAgent,
LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput, ListAgentsOutput,
ListProfiles, ListProfilesOutput, ProfileAvailability, ReadAgentContext, ReadAgentContextInput,
ReadAgentContextOutput, ReferenceProfiles, ReferenceProfilesOutput, SaveProfile,
SaveProfileInput, SaveProfileOutput, UpdateAgentContext, UpdateAgentContextInput,
};
pub use error::AppError;
pub use git::{
GitBranches, GitBranchesInput, GitBranchesOutput, GitCheckout, GitCheckoutInput, GitCommit,
GitCommitInput, GitCommitOutput, GitGraph, GitGraphInput, GitGraphOutput, GitInit, GitInitInput,
GitLog, GitLogInput, GitLogOutput, GitStage, GitStagePathInput, GitStatus, GitStatusInput,
GitStatusOutput, GitUnstage,
};
pub use health::{HealthInput, HealthReport, HealthUseCase};
pub use remote::{ConnectRemote, ConnectRemoteInput, ConnectRemoteOutput};
pub use layout::{
CreateLayout, CreateLayoutInput, CreateLayoutOutput, DeleteLayout, DeleteLayoutInput,
DeleteLayoutOutput, LayoutInfo, LayoutKind, LayoutOperation, LayoutsDoc, ListLayouts,
ListLayoutsInput, ListLayoutsOutput, LoadLayout, LoadLayoutInput, LoadLayoutOutput,
MutateLayout, MutateLayoutInput, MutateLayoutOutput, NamedLayout, RenameLayout,
RenameLayoutInput, SetActiveLayout, SetActiveLayoutInput, LAYOUTS_FILE,
};
pub use project::{
CloseProject, CloseProjectInput, CloseProjectOutput, CloseTab, CloseTabInput, CreateProject,
CreateProjectInput, CreateProjectOutput, ListProjects, ListProjectsOutput, OpenProject,
OpenProjectInput, OpenProjectOutput, ProjectMeta,
};
pub use template::{
AgentDrift, CreateAgentFromTemplate, CreateAgentFromTemplateInput,
CreateAgentFromTemplateOutput, CreateTemplate, CreateTemplateInput, CreateTemplateOutput,
DeleteTemplate, DeleteTemplateInput, DetectAgentDrift, DetectAgentDriftInput,
DetectAgentDriftOutput, ListTemplates, ListTemplatesOutput, SyncAgentWithTemplate,
SyncAgentWithTemplateInput, SyncAgentWithTemplateOutput, UpdateTemplate, UpdateTemplateInput,
UpdateTemplateOutput,
};
pub use terminal::{
CloseTerminal, CloseTerminalInput, CloseTerminalOutput, OpenTerminal, OpenTerminalInput,
OpenTerminalOutput, ResizeTerminal, ResizeTerminalInput, TerminalSessions, WriteToTerminal,
WriteToTerminalInput,
};
pub use window::{MoveTabToNewWindow, MoveTabToNewWindowInput, MoveTabToNewWindowOutput};

View File

@ -0,0 +1,97 @@
//! [`CloseProject`] / [`CloseTab`] (ARCHITECTURE §6).
//!
//! In L2 there are no PTYs to release yet, so closing is essentially *persisting
//! the current state*. The use case takes the workspace image to persist (the
//! UI owns the windows/tabs arrangement) and saves it through the
//! [`ProjectStore`]. It is written so the L3 "release PTYs" step slots in here
//! without changing the call sites.
use std::sync::Arc;
use domain::ports::ProjectStore;
use domain::{ProjectId, Workspace};
use crate::error::AppError;
/// Input for [`CloseProject::execute`].
#[derive(Debug, Clone, PartialEq)]
pub struct CloseProjectInput {
/// The project being closed.
pub project_id: ProjectId,
/// The workspace state to persist (windows/tabs/layouts). `None` skips
/// persistence (e.g. the UI has nothing to save).
pub workspace: Option<Workspace>,
}
/// Output of [`CloseProject::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CloseProjectOutput {
/// The project that was closed.
pub project_id: ProjectId,
}
/// Closes a project: persists the workspace state and releases resources.
pub struct CloseProject {
store: Arc<dyn ProjectStore>,
}
impl CloseProject {
/// Builds the use case from its injected port.
#[must_use]
pub fn new(store: Arc<dyn ProjectStore>) -> Self {
Self { store }
}
/// Executes the close.
///
/// # Errors
/// [`AppError::Store`] if persisting the workspace fails.
pub async fn execute(&self, input: CloseProjectInput) -> Result<CloseProjectOutput, AppError> {
if let Some(workspace) = &input.workspace {
self.store.save_workspace(workspace).await?;
}
// L3 will release the project's PTYs here.
Ok(CloseProjectOutput {
project_id: input.project_id,
})
}
}
/// Input for [`CloseTab::execute`] — closing one tab (a single open project).
#[derive(Debug, Clone, PartialEq)]
pub struct CloseTabInput {
/// The project shown in the tab being closed.
pub project_id: ProjectId,
/// The workspace state to persist after the tab is removed.
pub workspace: Option<Workspace>,
}
/// Closes a single tab. In L2 this delegates to the same persistence path as
/// [`CloseProject`]; it exists as a distinct intention so the multi-window lot
/// (L10) can give it tab-specific behaviour without touching callers.
pub struct CloseTab {
inner: CloseProject,
}
impl CloseTab {
/// Builds the use case from its injected port.
#[must_use]
pub fn new(store: Arc<dyn ProjectStore>) -> Self {
Self {
inner: CloseProject::new(store),
}
}
/// Executes the tab close.
///
/// # Errors
/// [`AppError::Store`] if persisting the workspace fails.
pub async fn execute(&self, input: CloseTabInput) -> Result<CloseProjectOutput, AppError> {
self.inner
.execute(CloseProjectInput {
project_id: input.project_id,
workspace: input.workspace,
})
.await
}
}

View File

@ -0,0 +1,120 @@
//! [`CreateProject`] — create a project from a project root (ARCHITECTURE §6).
//!
//! Responsibilities (and *only* these):
//! 1. validate the root (absolute path — enforced by [`ProjectPath`]) and name,
//! 2. enforce the cross-aggregate uniqueness invariant `(remote, root)` — this
//! lives here, not in the domain, because it requires knowledge of *all*
//! projects (a repository concern, ARCHITECTURE §3.2),
//! 3. create the `.ideai/` directory and write `project.json`,
//! 4. register the project via the [`ProjectStore`],
//! 5. publish [`DomainEvent::ProjectCreated`].
use std::sync::Arc;
use domain::ports::{Clock, EventBus, FileSystem, IdGenerator, ProjectStore, RemotePath};
use domain::{DomainEvent, Project, ProjectId, ProjectPath, RemoteRef};
use crate::error::AppError;
use super::meta::{join_root, to_json_bytes, ProjectMeta, IDEAI_DIR, PROJECT_FILE};
/// Input for [`CreateProject::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CreateProjectInput {
/// Display name of the project.
pub name: String,
/// Absolute project root (validated into a [`ProjectPath`]).
pub root: String,
/// Where the project lives. Defaults to [`RemoteRef::Local`] when `None`.
pub remote: Option<RemoteRef>,
/// Default agent profile id, if already chosen.
pub default_profile_id: Option<String>,
}
/// Output of [`CreateProject::execute`]: the freshly-created project.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CreateProjectOutput {
/// The created project.
pub project: Project,
}
/// Creates a project: inits `.ideai/`, writes `project.json`, registers it.
pub struct CreateProject {
store: Arc<dyn ProjectStore>,
fs: Arc<dyn FileSystem>,
ids: Arc<dyn IdGenerator>,
clock: Arc<dyn Clock>,
events: Arc<dyn EventBus>,
}
impl CreateProject {
/// Builds the use case from its injected ports.
#[must_use]
pub fn new(
store: Arc<dyn ProjectStore>,
fs: Arc<dyn FileSystem>,
ids: Arc<dyn IdGenerator>,
clock: Arc<dyn Clock>,
events: Arc<dyn EventBus>,
) -> Self {
Self {
store,
fs,
ids,
clock,
events,
}
}
/// Executes project creation.
///
/// # Errors
/// - [`AppError::Invalid`] if the name is empty or the root is not absolute,
/// - [`AppError::Invalid`] if a project already exists for the same
/// `(remote, root)` (uniqueness invariant),
/// - [`AppError::FileSystem`] / [`AppError::Store`] on I/O failures.
pub async fn execute(
&self,
input: CreateProjectInput,
) -> Result<CreateProjectOutput, AppError> {
let root = ProjectPath::new(input.root).map_err(|e| AppError::Invalid(e.to_string()))?;
let remote = input.remote.unwrap_or(RemoteRef::Local);
// (1+2) Uniqueness invariant on (remote, root) — repository-level.
let existing = self.store.list_projects().await?;
if existing
.iter()
.any(|p| p.remote == remote && p.root == root)
{
return Err(AppError::Invalid(format!(
"a project already exists at {} for this remote",
root.as_str()
)));
}
// Build the validated aggregate (enforces non-empty name).
let id = ProjectId::from_uuid(self.ids.new_uuid());
let created_at = self.clock.now_millis();
let project = Project::new(id, input.name, root.clone(), remote, created_at)
.map_err(|e| AppError::Invalid(e.to_string()))?;
// (3) Materialise `.ideai/` and write `project.json`.
let ideai_dir = join_root(&root, IDEAI_DIR);
self.fs.create_dir_all(&RemotePath::new(ideai_dir)).await?;
let meta = ProjectMeta::from_project(&project, input.default_profile_id);
let meta_path = join_root(&root, &format!("{IDEAI_DIR}/{PROJECT_FILE}"));
self.fs
.write(&RemotePath::new(meta_path), &to_json_bytes(&meta)?)
.await?;
// (4) Register in the known-projects registry.
self.store.save_project(&project).await?;
// (5) Announce.
self.events
.publish(DomainEvent::ProjectCreated { project_id: id });
Ok(CreateProjectOutput { project })
}
}

View File

@ -0,0 +1,99 @@
//! [`ProjectMeta`] — the on-disk shape of `.ideai/project.json` (ARCHITECTURE §9.1).
//!
//! This is the *project-local* metadata that travels with the code (it lives
//! inside the project root, is versionable, and is independent of the machine).
//! The known-projects **registry** is a separate, machine-local concern owned by
//! the [`domain::ports::ProjectStore`] adapter (ARCHITECTURE §9.2).
use serde::{Deserialize, Serialize};
use domain::{Project, ProjectId, ProjectPath, RemoteRef};
use crate::error::AppError;
/// The `.ideai/` directory name inside a project root.
pub(crate) const IDEAI_DIR: &str = ".ideai";
/// The project-meta file name inside `.ideai/`.
pub(crate) const PROJECT_FILE: &str = "project.json";
/// The agent manifest file name inside `.ideai/`.
pub(crate) const AGENTS_FILE: &str = "agents.json";
/// Current schema version of `project.json`.
pub(crate) const PROJECT_META_VERSION: u32 = 1;
/// Serialised contents of `.ideai/project.json`.
///
/// Carries the project's identity and the metadata needed to reopen it: its
/// stable id, display name, the default agent profile, the remote reference and
/// the creation timestamp. The `root` itself is *not* stored here — the file
/// already lives at `<root>/.ideai/project.json`, so the root is implied by the
/// file's location (and authoritatively held by the registry).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ProjectMeta {
/// Schema version of this file.
pub version: u32,
/// Stable project id (matches the registry entry).
pub id: ProjectId,
/// Display name.
pub name: String,
/// Default agent profile id, if one has been chosen (`null` until first-run
/// / profile selection in later lots).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default_profile_id: Option<String>,
/// Where the project physically lives.
pub remote: RemoteRef,
/// Creation timestamp, epoch milliseconds.
pub created_at: i64,
}
impl ProjectMeta {
/// Builds the meta image from a [`Project`].
#[must_use]
pub fn from_project(project: &Project, default_profile_id: Option<String>) -> Self {
Self {
version: PROJECT_META_VERSION,
id: project.id,
name: project.name.clone(),
default_profile_id,
remote: project.remote.clone(),
created_at: project.created_at,
}
}
/// Reconstructs a validated [`Project`] from this meta and its (registry-known)
/// root.
///
/// # Errors
/// Returns [`AppError::Invalid`] if the stored fields violate a domain
/// invariant (e.g. empty name).
pub fn into_project(self, root: ProjectPath) -> Result<Project, AppError> {
Project::new(self.id, self.name, root, self.remote, self.created_at)
.map_err(|e| AppError::Invalid(e.to_string()))
}
}
/// Serialises a value to pretty JSON bytes, mapping failures to [`AppError`].
pub(crate) fn to_json_bytes<T: Serialize>(value: &T) -> Result<Vec<u8>, AppError> {
serde_json::to_vec_pretty(value)
.map(|mut v| {
v.push(b'\n');
v
})
.map_err(|e| AppError::Store(format!("serialize failed: {e}")))
}
/// Deserialises JSON bytes, mapping failures to [`AppError`].
pub(crate) fn from_json_bytes<T: for<'de> Deserialize<'de>>(bytes: &[u8]) -> Result<T, AppError> {
serde_json::from_slice(bytes).map_err(|e| AppError::Store(format!("deserialize failed: {e}")))
}
/// Joins a project root with a relative path segment using a POSIX-style
/// separator. Paths inside `.ideai/` are always written with `/`, which is valid
/// on every platform we target (Windows `tokio::fs` accepts `/`).
pub(crate) fn join_root(root: &ProjectPath, rel: &str) -> String {
let base = root.as_str().trim_end_matches(['/', '\\']);
format!("{base}/{rel}")
}

View File

@ -0,0 +1,24 @@
//! Project life-cycle use cases (ARCHITECTURE §6, L2).
//!
//! Each use case is a struct carrying its ports as `Arc<dyn Port>` and exposing
//! a single `execute(input) -> Result<output, AppError>` method
//! (**Single Responsibility**). They talk **only** to the domain ports, never to
//! concrete adapters; the composition root injects the implementations.
//!
//! - [`CreateProject`] — validate the root, create `.ideai/project.json`,
//! register the project, publish [`domain::DomainEvent::ProjectCreated`].
//! - [`OpenProject`] — load a project and its `.ideai/project.json` meta (plus,
//! tolerantly, the agent manifest if present).
//! - [`CloseProject`] / [`CloseTab`] — persist state and release resources
//! (no PTYs yet in L2).
//! - [`ListProjects`] — list known projects from the registry.
mod close;
mod create;
pub(crate) mod meta;
mod open;
pub use close::{CloseProject, CloseProjectInput, CloseProjectOutput, CloseTab, CloseTabInput};
pub use create::{CreateProject, CreateProjectInput, CreateProjectOutput};
pub use meta::ProjectMeta;
pub use open::{ListProjects, ListProjectsOutput, OpenProject, OpenProjectInput, OpenProjectOutput};

View File

@ -0,0 +1,115 @@
//! [`OpenProject`] and [`ListProjects`] (ARCHITECTURE §6).
use std::sync::Arc;
use domain::ports::{FileSystem, ProjectStore, RemotePath};
use domain::{AgentManifest, Project, ProjectId};
use crate::error::AppError;
use super::meta::{
from_json_bytes, join_root, ProjectMeta, AGENTS_FILE, IDEAI_DIR, PROJECT_FILE,
};
/// Input for [`OpenProject::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OpenProjectInput {
/// Id of the project to open (as known by the registry).
pub project_id: ProjectId,
}
/// Output of [`OpenProject::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OpenProjectOutput {
/// The opened project (registry source of truth for id/name/root/remote).
pub project: Project,
/// The project-local meta read from `.ideai/project.json`, if present and
/// readable. Read tolerantly: a missing/corrupt file does not fail the open.
pub meta: Option<ProjectMeta>,
/// The agent manifest read from `.ideai/agents.json`, if present. Tolerant:
/// absent in a brand-new project.
pub manifest: Option<AgentManifest>,
}
/// Loads a project, its `.ideai/project.json` meta and (tolerantly) its manifest.
pub struct OpenProject {
store: Arc<dyn ProjectStore>,
fs: Arc<dyn FileSystem>,
}
impl OpenProject {
/// Builds the use case from its injected ports.
#[must_use]
pub fn new(store: Arc<dyn ProjectStore>, fs: Arc<dyn FileSystem>) -> Self {
Self { store, fs }
}
/// Executes the open.
///
/// # Errors
/// - [`AppError::NotFound`] if the registry has no such project,
/// - [`AppError::Store`] on registry I/O failure.
///
/// Reading the `.ideai/` files is **tolerant**: their absence or a parse
/// failure yields `None` rather than an error, so a project whose `.ideai/`
/// was deleted can still be opened.
pub async fn execute(&self, input: OpenProjectInput) -> Result<OpenProjectOutput, AppError> {
let project = self.store.load_project(input.project_id).await?;
let meta = self
.read_optional_json::<ProjectMeta>(&project, PROJECT_FILE)
.await;
let manifest = self
.read_optional_json::<AgentManifest>(&project, AGENTS_FILE)
.await;
Ok(OpenProjectOutput {
project,
meta,
manifest,
})
}
/// Reads and parses a JSON file inside the project's `.ideai/`, tolerating
/// any failure (missing file, I/O error, parse error) by returning `None`.
async fn read_optional_json<T: for<'de> serde::Deserialize<'de>>(
&self,
project: &Project,
file: &str,
) -> Option<T> {
let path = RemotePath::new(join_root(&project.root, &format!("{IDEAI_DIR}/{file}")));
match self.fs.read(&path).await {
Ok(bytes) => from_json_bytes::<T>(&bytes).ok(),
Err(_) => None,
}
}
}
/// Output of [`ListProjects::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ListProjectsOutput {
/// All projects known to the registry.
pub projects: Vec<Project>,
}
/// Lists the projects known to the registry.
pub struct ListProjects {
store: Arc<dyn ProjectStore>,
}
impl ListProjects {
/// Builds the use case from its injected port.
#[must_use]
pub fn new(store: Arc<dyn ProjectStore>) -> Self {
Self { store }
}
/// Executes the listing.
///
/// # Errors
/// [`AppError::Store`] on registry I/O failure.
pub async fn execute(&self) -> Result<ListProjectsOutput, AppError> {
let projects = self.store.list_projects().await?;
Ok(ListProjectsOutput { projects })
}
}

View File

@ -0,0 +1,6 @@
//! Remote use cases (ARCHITECTURE §6, L9). Connecting a project's
//! [`domain::ports::RemoteHost`] (local / SSH / WSL) and validating its root.
mod usecases;
pub use usecases::{ConnectRemote, ConnectRemoteInput, ConnectRemoteOutput};

View File

@ -0,0 +1,75 @@
//! Remote-connection use case (ARCHITECTURE §6, L9).
//!
//! [`ConnectRemote`] establishes a project's [`RemoteHost`] and validates that
//! its root is reachable on the host's filesystem. It speaks only to the
//! [`RemoteHost`] port, so it behaves identically for a local, SSH or WSL host
//! (Liskov) and is fully testable with a mock host.
use std::sync::Arc;
use domain::ports::{EventBus, RemoteHost, RemotePath};
use domain::{DomainEvent, ProjectId, RemoteKind};
use crate::error::AppError;
/// Input for [`ConnectRemote::execute`].
#[derive(Clone)]
pub struct ConnectRemoteInput {
/// The host strategy to connect through (built from the project's `RemoteRef`).
pub host: Arc<dyn RemoteHost>,
/// The project being connected (for the emitted event).
pub project_id: ProjectId,
/// Absolute root path to validate on the host.
pub root: String,
}
/// Output of [`ConnectRemote::execute`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ConnectRemoteOutput {
/// The kind of host that was connected.
pub kind: RemoteKind,
}
/// Connects a project's remote host and checks its root is reachable.
pub struct ConnectRemote {
events: Arc<dyn EventBus>,
}
impl ConnectRemote {
/// Builds the use case from the [`EventBus`].
#[must_use]
pub fn new(events: Arc<dyn EventBus>) -> Self {
Self { events }
}
/// Connects the host and validates the root exists, announcing
/// [`DomainEvent::RemoteConnected`] on success.
///
/// # Errors
/// - [`AppError::Remote`] if the connection fails,
/// - [`AppError::NotFound`] if the root does not exist on the host,
/// - [`AppError::FileSystem`] on an I/O failure while probing the root.
pub async fn execute(
&self,
input: ConnectRemoteInput,
) -> Result<ConnectRemoteOutput, AppError> {
input.host.connect().await?;
let fs = input.host.file_system();
let exists = fs.exists(&RemotePath::new(input.root.clone())).await?;
if !exists {
return Err(AppError::NotFound(format!(
"remote root {} is not reachable",
input.root
)));
}
self.events.publish(DomainEvent::RemoteConnected {
project_id: input.project_id,
});
Ok(ConnectRemoteOutput {
kind: input.host.kind(),
})
}
}

View File

@ -0,0 +1,17 @@
//! Template & synchronisation use cases (ARCHITECTURE §6, §8; L7).
//!
//! Templates are reusable agent contexts stored in the global IDE store, with a
//! monotonic version. This module owns their CRUD, the template→agent
//! instantiation, and the drift-detection / synchronisation flow that keeps
//! `synchronized` agents in step with their template.
mod usecases;
pub use usecases::{
AgentDrift, CreateAgentFromTemplate, CreateAgentFromTemplateInput,
CreateAgentFromTemplateOutput, CreateTemplate, CreateTemplateInput, CreateTemplateOutput,
DeleteTemplate, DeleteTemplateInput, DetectAgentDrift, DetectAgentDriftInput,
DetectAgentDriftOutput, ListTemplates, ListTemplatesOutput, SyncAgentWithTemplate,
SyncAgentWithTemplateInput, SyncAgentWithTemplateOutput, UpdateTemplate, UpdateTemplateInput,
UpdateTemplateOutput,
};

View File

@ -0,0 +1,495 @@
//! Template & synchronisation use cases (ARCHITECTURE §6, §8; L7).
//!
//! Two concerns live here:
//! - **Templates** (global IDE store): CRUD + monotonic versioning
//! ([`CreateTemplate`], [`UpdateTemplate`], [`ListTemplates`], [`DeleteTemplate`]).
//! - **Template → agent link**: instantiating an agent from a template
//! ([`CreateAgentFromTemplate`]), detecting when a synchronized agent is behind
//! its template ([`DetectAgentDrift`]), and applying the update
//! ([`SyncAgentWithTemplate`]).
//!
//! Every use case talks only to ports ([`TemplateStore`], [`AgentContextStore`],
//! [`IdGenerator`], [`EventBus`]).
use std::sync::Arc;
use domain::ports::{AgentContextStore, EventBus, IdGenerator, StoreError, TemplateStore};
use domain::{
Agent, AgentId, AgentManifest, AgentOrigin, AgentTemplate, DomainEvent, ManifestEntry,
MarkdownDoc, ProfileId, Project, TemplateId, TemplateVersion,
};
use crate::agent::unique_md_path;
use crate::error::AppError;
// ---------------------------------------------------------------------------
// CreateTemplate
// ---------------------------------------------------------------------------
/// Input for [`CreateTemplate::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CreateTemplateInput {
/// Display name.
pub name: String,
/// Initial Markdown content.
pub content: String,
/// Default runtime profile for agents created from this template.
pub default_profile_id: ProfileId,
}
/// Output of [`CreateTemplate::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CreateTemplateOutput {
/// The created template (at [`TemplateVersion::INITIAL`]).
pub template: AgentTemplate,
}
/// Creates a template in the global store at the initial version.
pub struct CreateTemplate {
templates: Arc<dyn TemplateStore>,
ids: Arc<dyn IdGenerator>,
}
impl CreateTemplate {
/// Builds the use case from its ports.
#[must_use]
pub fn new(templates: Arc<dyn TemplateStore>, ids: Arc<dyn IdGenerator>) -> Self {
Self { templates, ids }
}
/// Executes creation.
///
/// # Errors
/// - [`AppError::Invalid`] if the name is empty,
/// - [`AppError::Store`] on persistence failure.
pub async fn execute(
&self,
input: CreateTemplateInput,
) -> Result<CreateTemplateOutput, AppError> {
let id = TemplateId::from_uuid(self.ids.new_uuid());
let template = AgentTemplate::new(
id,
input.name,
MarkdownDoc::new(input.content),
input.default_profile_id,
)
.map_err(|e| AppError::Invalid(e.to_string()))?;
self.templates.save(&template).await?;
Ok(CreateTemplateOutput { template })
}
}
// ---------------------------------------------------------------------------
// UpdateTemplate
// ---------------------------------------------------------------------------
/// Input for [`UpdateTemplate::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UpdateTemplateInput {
/// Template to update.
pub template_id: TemplateId,
/// New Markdown content.
pub content: String,
}
/// Output of [`UpdateTemplate::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UpdateTemplateOutput {
/// The updated template (version bumped by one).
pub template: AgentTemplate,
}
/// Updates a template's content and **bumps its version** (monotonic, §8.1),
/// announcing [`DomainEvent::TemplateUpdated`] so drift can be re-evaluated.
pub struct UpdateTemplate {
templates: Arc<dyn TemplateStore>,
events: Arc<dyn EventBus>,
}
impl UpdateTemplate {
/// Builds the use case from its ports.
#[must_use]
pub fn new(templates: Arc<dyn TemplateStore>, events: Arc<dyn EventBus>) -> Self {
Self { templates, events }
}
/// Executes the update.
///
/// # Errors
/// - [`AppError::NotFound`] if the template is unknown,
/// - [`AppError::Store`] on persistence failure.
pub async fn execute(
&self,
input: UpdateTemplateInput,
) -> Result<UpdateTemplateOutput, AppError> {
let current = self.templates.get(input.template_id).await?;
let updated = current.with_updated_content(MarkdownDoc::new(input.content));
self.templates.save(&updated).await?;
self.events.publish(DomainEvent::TemplateUpdated {
template_id: updated.id,
version: updated.version,
});
Ok(UpdateTemplateOutput { template: updated })
}
}
// ---------------------------------------------------------------------------
// ListTemplates / DeleteTemplate
// ---------------------------------------------------------------------------
/// Output of [`ListTemplates::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ListTemplatesOutput {
/// All templates in the global store.
pub templates: Vec<AgentTemplate>,
}
/// Lists the templates in the global store.
pub struct ListTemplates {
templates: Arc<dyn TemplateStore>,
}
impl ListTemplates {
/// Builds the use case.
#[must_use]
pub fn new(templates: Arc<dyn TemplateStore>) -> Self {
Self { templates }
}
/// Lists templates.
///
/// # Errors
/// [`AppError::Store`] on persistence failure.
pub async fn execute(&self) -> Result<ListTemplatesOutput, AppError> {
Ok(ListTemplatesOutput {
templates: self.templates.list().await?,
})
}
}
/// Input for [`DeleteTemplate::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DeleteTemplateInput {
/// Template to delete.
pub template_id: TemplateId,
}
/// Deletes a template from the global store.
///
/// Agents previously created from it keep their `.md` (their `origin` still
/// references the now-absent template; drift detection simply finds nothing to
/// compare against).
pub struct DeleteTemplate {
templates: Arc<dyn TemplateStore>,
}
impl DeleteTemplate {
/// Builds the use case.
#[must_use]
pub fn new(templates: Arc<dyn TemplateStore>) -> Self {
Self { templates }
}
/// Deletes the template.
///
/// # Errors
/// - [`AppError::NotFound`] if the template is unknown,
/// - [`AppError::Store`] on persistence failure.
pub async fn execute(&self, input: DeleteTemplateInput) -> Result<(), AppError> {
self.templates.delete(input.template_id).await?;
Ok(())
}
}
// ---------------------------------------------------------------------------
// CreateAgentFromTemplate
// ---------------------------------------------------------------------------
/// Input for [`CreateAgentFromTemplate::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CreateAgentFromTemplateInput {
/// The project that owns the agent.
pub project: Project,
/// Source template.
pub template_id: TemplateId,
/// Optional agent name; defaults to the template's name.
pub name: Option<String>,
/// Whether the agent tracks the template (`true` ⇒ future syncs apply).
pub synchronized: bool,
}
/// Output of [`CreateAgentFromTemplate::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CreateAgentFromTemplateOutput {
/// The created agent.
pub agent: Agent,
}
/// Instantiates a project agent from a template: copies the template's
/// `content_md` into the agent's `.md`, links the origin to the template at its
/// current version, and records the manifest entry.
pub struct CreateAgentFromTemplate {
templates: Arc<dyn TemplateStore>,
contexts: Arc<dyn AgentContextStore>,
ids: Arc<dyn IdGenerator>,
events: Arc<dyn EventBus>,
}
impl CreateAgentFromTemplate {
/// Builds the use case from its ports.
#[must_use]
pub fn new(
templates: Arc<dyn TemplateStore>,
contexts: Arc<dyn AgentContextStore>,
ids: Arc<dyn IdGenerator>,
events: Arc<dyn EventBus>,
) -> Self {
Self {
templates,
contexts,
ids,
events,
}
}
/// Executes creation from a template.
///
/// # Errors
/// - [`AppError::NotFound`] if the template is unknown,
/// - [`AppError::Invalid`] if the resulting agent/manifest is invalid,
/// - [`AppError::Store`] on persistence failure.
pub async fn execute(
&self,
input: CreateAgentFromTemplateInput,
) -> Result<CreateAgentFromTemplateOutput, AppError> {
let template = self.templates.get(input.template_id).await?;
let manifest = self.contexts.load_manifest(&input.project).await?;
let name = input.name.unwrap_or_else(|| template.name.clone());
let id = AgentId::from_uuid(self.ids.new_uuid());
let md_path = unique_md_path(&name, &manifest);
let origin = AgentOrigin::FromTemplate {
template_id: template.id,
synced_template_version: template.version,
};
let agent = Agent::new(
id,
name,
md_path,
template.default_profile_id,
origin,
input.synchronized,
)
.map_err(|e| AppError::Invalid(e.to_string()))?;
let mut entries = manifest.entries;
entries.push(ManifestEntry::from_agent(&agent));
let manifest = AgentManifest::new(manifest.version, entries)
.map_err(|e| AppError::Invalid(e.to_string()))?;
self.contexts.save_manifest(&input.project, &manifest).await?;
// Seed the agent context with the template content.
self.contexts
.write_context(&input.project, &agent.id, &template.content_md)
.await?;
self.events.publish(DomainEvent::LayoutChanged {
project_id: input.project.id,
});
Ok(CreateAgentFromTemplateOutput { agent })
}
}
// ---------------------------------------------------------------------------
// DetectAgentDrift
// ---------------------------------------------------------------------------
/// One drifting agent: its template moved ahead of the agent's synced version.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AgentDrift {
/// The drifting agent.
pub agent_id: AgentId,
/// Version the agent is currently synced to.
pub from: TemplateVersion,
/// Version available from the template.
pub to: TemplateVersion,
}
/// Input for [`DetectAgentDrift::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DetectAgentDriftInput {
/// The project whose agents to check.
pub project: Project,
}
/// Output of [`DetectAgentDrift::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DetectAgentDriftOutput {
/// Agents whose template has a newer version (only `synchronized` ones).
pub drifts: Vec<AgentDrift>,
}
/// Detects which synchronized agents are behind their template
/// (`template.version > synced_template_version`, §8.2), announcing
/// [`DomainEvent::AgentDriftDetected`] for each.
pub struct DetectAgentDrift {
templates: Arc<dyn TemplateStore>,
contexts: Arc<dyn AgentContextStore>,
events: Arc<dyn EventBus>,
}
impl DetectAgentDrift {
/// Builds the use case from its ports.
#[must_use]
pub fn new(
templates: Arc<dyn TemplateStore>,
contexts: Arc<dyn AgentContextStore>,
events: Arc<dyn EventBus>,
) -> Self {
Self {
templates,
contexts,
events,
}
}
/// Computes the drift set.
///
/// # Errors
/// [`AppError::Store`] on persistence failure (a *deleted* template is not an
/// error — that agent simply has nothing to drift against).
pub async fn execute(
&self,
input: DetectAgentDriftInput,
) -> Result<DetectAgentDriftOutput, AppError> {
let manifest = self.contexts.load_manifest(&input.project).await?;
let mut drifts = Vec::new();
for entry in &manifest.entries {
// Only synchronized, template-backed agents can drift.
if !entry.synchronized {
continue;
}
let (Some(template_id), Some(synced)) =
(entry.template_id, entry.synced_template_version)
else {
continue;
};
let template = match self.templates.get(template_id).await {
Ok(t) => t,
Err(StoreError::NotFound) => continue,
Err(e) => return Err(e.into()),
};
if template.version > synced {
let drift = AgentDrift {
agent_id: entry.agent_id,
from: synced,
to: template.version,
};
self.events.publish(DomainEvent::AgentDriftDetected {
agent_id: drift.agent_id,
from: drift.from,
to: drift.to,
});
drifts.push(drift);
}
}
Ok(DetectAgentDriftOutput { drifts })
}
}
// ---------------------------------------------------------------------------
// SyncAgentWithTemplate
// ---------------------------------------------------------------------------
/// Input for [`SyncAgentWithTemplate::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SyncAgentWithTemplateInput {
/// The owning project.
pub project: Project,
/// The agent to bring up to date.
pub agent_id: AgentId,
}
/// Output of [`SyncAgentWithTemplate::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SyncAgentWithTemplateOutput {
/// Whether an update was applied (`false` for a non-synchronized or
/// scratch agent — those are intentionally left untouched, §8.4).
pub synced: bool,
/// The version the agent is now at, when a sync happened.
pub version: Option<TemplateVersion>,
}
/// Applies a template update to a synchronized agent: **replaces** the agent's
/// `.md` with the template content (the context of a synchronized agent is
/// "owned" by the template, §8.3) and records the new synced version. Agents
/// that are not `synchronized` (or are `scratch`) are left untouched.
pub struct SyncAgentWithTemplate {
templates: Arc<dyn TemplateStore>,
contexts: Arc<dyn AgentContextStore>,
events: Arc<dyn EventBus>,
}
impl SyncAgentWithTemplate {
/// Builds the use case from its ports.
#[must_use]
pub fn new(
templates: Arc<dyn TemplateStore>,
contexts: Arc<dyn AgentContextStore>,
events: Arc<dyn EventBus>,
) -> Self {
Self {
templates,
contexts,
events,
}
}
/// Executes the sync for one agent.
///
/// # Errors
/// - [`AppError::NotFound`] if the agent or its template is unknown,
/// - [`AppError::Store`] on persistence failure.
pub async fn execute(
&self,
input: SyncAgentWithTemplateInput,
) -> Result<SyncAgentWithTemplateOutput, AppError> {
let mut manifest = self.contexts.load_manifest(&input.project).await?;
let idx = manifest
.entries
.iter()
.position(|e| e.agent_id == input.agent_id)
.ok_or_else(|| AppError::NotFound(format!("agent {}", input.agent_id)))?;
let entry = &manifest.entries[idx];
// Non-synchronized / scratch agents are never auto-updated (§8.4).
let Some(template_id) = entry.template_id.filter(|_| entry.synchronized) else {
return Ok(SyncAgentWithTemplateOutput {
synced: false,
version: None,
});
};
let template = self.templates.get(template_id).await?;
manifest.entries[idx].synced_template_version = Some(template.version);
// Persist the manifest (revalidated) and overwrite the agent context.
let manifest = AgentManifest::new(manifest.version, manifest.entries)
.map_err(|e| AppError::Invalid(e.to_string()))?;
self.contexts.save_manifest(&input.project, &manifest).await?;
self.contexts
.write_context(&input.project, &input.agent_id, &template.content_md)
.await?;
self.events.publish(DomainEvent::AgentSynced {
agent_id: input.agent_id,
to: template.version,
});
Ok(SyncAgentWithTemplateOutput {
synced: true,
version: Some(template.version),
})
}
}

View File

@ -0,0 +1,35 @@
//! Terminal use cases (ARCHITECTURE §6, L3).
//!
//! Each use case is a struct carrying its ports as `Arc<dyn Port>` and exposing a
//! single `execute(input) -> Result<output, AppError>` method (**Single
//! Responsibility**). They talk **only** to the [`domain::ports::PtyPort`] (plus
//! the [`domain::ports::EventBus`] for `OpenTerminal`); the composition root
//! injects the concrete [`infrastructure::PortablePtyAdapter`].
//!
//! - [`OpenTerminal`] — resolve the cwd, spawn a PTY, create a
//! [`domain::TerminalSession`], register the live handle, publish an event.
//! - [`WriteToTerminal`] — forward bytes (keystrokes) to a PTY.
//! - [`ResizeTerminal`] — resize a PTY.
//! - [`CloseTerminal`] — kill a PTY and forget its handle.
//!
//! # Where the active-session registry lives, and why
//!
//! The domain [`PtyPort`] is *handle-oriented*: `spawn` returns a
//! [`domain::ports::PtyHandle`] that every later call (`write`/`resize`/`kill`)
//! must reference. Something has to remember, per [`SessionId`], the live
//! `PtyHandle` (and the `TerminalSession` snapshot) between IPC calls. That state
//! is **not domain state** (it is the in-flight wiring of an I/O resource), and
//! it must not live in the adapter alone (other use cases need to address a
//! session by id). It therefore lives in [`TerminalSessions`], an **application
//! service** injected into the terminal use cases (and held in the composition
//! root behind an `Arc`). This keeps the domain pure and the registry shared,
//! testable, and transport-agnostic.
mod registry;
mod usecases;
pub use registry::TerminalSessions;
pub use usecases::{
CloseTerminal, CloseTerminalInput, CloseTerminalOutput, OpenTerminal, OpenTerminalInput,
OpenTerminalOutput, ResizeTerminal, ResizeTerminalInput, WriteToTerminal, WriteToTerminalInput,
};

View File

@ -0,0 +1,81 @@
//! [`TerminalSessions`] — the active-terminal registry (application service).
//!
//! Maps a [`SessionId`] to the live [`PtyHandle`] and the [`TerminalSession`]
//! snapshot. Thread-safe (behind a [`Mutex`]); a single instance is shared
//! (as `Arc`) by all terminal use cases via the composition root. See the module
//! docs in `terminal/mod.rs` for the rationale of keeping this in the
//! application layer rather than the domain or the adapter.
use std::collections::HashMap;
use std::sync::Mutex;
use domain::ports::PtyHandle;
use domain::{SessionId, TerminalSession};
/// A registered, live terminal: its PTY handle plus the domain snapshot.
#[derive(Debug, Clone)]
struct Entry {
handle: PtyHandle,
session: TerminalSession,
}
/// In-memory registry of active terminal sessions.
#[derive(Default)]
pub struct TerminalSessions {
entries: Mutex<HashMap<SessionId, Entry>>,
}
impl TerminalSessions {
/// Creates an empty registry.
#[must_use]
pub fn new() -> Self {
Self {
entries: Mutex::new(HashMap::new()),
}
}
/// Inserts a freshly-opened session.
pub fn insert(&self, handle: PtyHandle, session: TerminalSession) {
if let Ok(mut map) = self.entries.lock() {
map.insert(session.id, Entry { handle, session });
}
}
/// Returns the [`PtyHandle`] for a session, if registered.
#[must_use]
pub fn handle(&self, id: &SessionId) -> Option<PtyHandle> {
self.entries
.lock()
.ok()
.and_then(|m| m.get(id).map(|e| e.handle.clone()))
}
/// Returns the [`TerminalSession`] snapshot for a session, if registered.
#[must_use]
pub fn session(&self, id: &SessionId) -> Option<TerminalSession> {
self.entries
.lock()
.ok()
.and_then(|m| m.get(id).map(|e| e.session.clone()))
}
/// Removes a session from the registry, returning its handle if present.
pub fn remove(&self, id: &SessionId) -> Option<PtyHandle> {
self.entries
.lock()
.ok()
.and_then(|mut m| m.remove(id).map(|e| e.handle))
}
/// Number of currently-registered sessions.
#[must_use]
pub fn len(&self) -> usize {
self.entries.lock().map(|m| m.len()).unwrap_or(0)
}
/// Whether the registry is empty.
#[must_use]
pub fn is_empty(&self) -> bool {
self.len() == 0
}
}

Some files were not shown because too many files have changed in this diff Show More