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:
97
crates/application/src/project/close.rs
Normal file
97
crates/application/src/project/close.rs
Normal 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
|
||||
}
|
||||
}
|
||||
120
crates/application/src/project/create.rs
Normal file
120
crates/application/src/project/create.rs
Normal 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 })
|
||||
}
|
||||
}
|
||||
99
crates/application/src/project/meta.rs
Normal file
99
crates/application/src/project/meta.rs
Normal 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}")
|
||||
}
|
||||
24
crates/application/src/project/mod.rs
Normal file
24
crates/application/src/project/mod.rs
Normal 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};
|
||||
115
crates/application/src/project/open.rs
Normal file
115
crates/application/src/project/open.rs
Normal 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 })
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user