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:
257
crates/domain/src/agent.rs
Normal file
257
crates/domain/src/agent.rs
Normal file
@ -0,0 +1,257 @@
|
||||
//! Agent entity, its origin and the project agent manifest.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::error::DomainError;
|
||||
use crate::ids::{AgentId, ProfileId, TemplateId};
|
||||
use crate::template::TemplateVersion;
|
||||
|
||||
/// Origin of an agent: created from scratch, or derived from a template.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", tag = "type")]
|
||||
pub enum AgentOrigin {
|
||||
/// Created from scratch; no template link.
|
||||
Scratch,
|
||||
/// Derived from a template, tracking the last synced version.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
FromTemplate {
|
||||
/// Source template.
|
||||
template_id: TemplateId,
|
||||
/// Template version recorded at the last successful sync.
|
||||
synced_template_version: TemplateVersion,
|
||||
},
|
||||
}
|
||||
|
||||
impl AgentOrigin {
|
||||
/// Returns the source template id, if any.
|
||||
#[must_use]
|
||||
pub fn template_id(&self) -> Option<TemplateId> {
|
||||
match self {
|
||||
Self::Scratch => None,
|
||||
Self::FromTemplate { template_id, .. } => Some(*template_id),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `true` if this origin is a template.
|
||||
#[must_use]
|
||||
pub fn is_from_template(&self) -> bool {
|
||||
matches!(self, Self::FromTemplate { .. })
|
||||
}
|
||||
}
|
||||
|
||||
/// A project-scoped agent.
|
||||
///
|
||||
/// Invariants enforced here:
|
||||
/// - `name` non-empty,
|
||||
/// - `context_path` is a relative, safe path (the `.md` lives under `.ideai/`),
|
||||
/// - `synchronized == true` ⇒ `origin == FromTemplate { .. }`.
|
||||
///
|
||||
/// Note: "`context` must exist at activation" and "`profile_id` must reference a
|
||||
/// known profile" are *runtime/cross-aggregate* invariants checked by the
|
||||
/// application layer (they require I/O or the profile registry), not by this
|
||||
/// pure constructor.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Agent {
|
||||
/// Stable identifier.
|
||||
pub id: AgentId,
|
||||
/// Display name.
|
||||
pub name: String,
|
||||
/// Relative path of the agent's `.md` within `.ideai/` (e.g. `agents/foo.md`).
|
||||
pub context_path: String,
|
||||
/// Runtime profile reference.
|
||||
pub profile_id: ProfileId,
|
||||
/// Origin of the agent.
|
||||
pub origin: AgentOrigin,
|
||||
/// Whether the agent tracks its template (only valid for template origins).
|
||||
pub synchronized: bool,
|
||||
}
|
||||
|
||||
impl Agent {
|
||||
/// Builds a validated agent.
|
||||
///
|
||||
/// # Errors
|
||||
/// - [`DomainError::EmptyField`] if `name` is empty,
|
||||
/// - [`DomainError::PathNotRelativeSafe`] if `context_path` is absolute or
|
||||
/// contains `..`,
|
||||
/// - [`DomainError::SyncRequiresTemplate`] if `synchronized` is `true` while
|
||||
/// `origin` is [`AgentOrigin::Scratch`].
|
||||
pub fn new(
|
||||
id: AgentId,
|
||||
name: impl Into<String>,
|
||||
context_path: impl Into<String>,
|
||||
profile_id: ProfileId,
|
||||
origin: AgentOrigin,
|
||||
synchronized: bool,
|
||||
) -> Result<Self, DomainError> {
|
||||
let name = name.into();
|
||||
let context_path = context_path.into();
|
||||
crate::validation::non_empty(&name, "agent.name")?;
|
||||
crate::validation::relative_safe(&context_path)?;
|
||||
if synchronized && !origin.is_from_template() {
|
||||
return Err(DomainError::SyncRequiresTemplate);
|
||||
}
|
||||
Ok(Self {
|
||||
id,
|
||||
name,
|
||||
context_path,
|
||||
profile_id,
|
||||
origin,
|
||||
synchronized,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// One entry in the project agent manifest (`.ideai/agents.json`).
|
||||
///
|
||||
/// This is the **persisted form of an [`Agent`]** (ARCHITECTURE §9.1): the
|
||||
/// manifest is the source of truth for a project's agents, so each entry carries
|
||||
/// everything needed to reconstruct the agent — its `name` and `profile_id`
|
||||
/// included (without them the IDE could not list agents or resolve the profile to
|
||||
/// launch). The template link is kept flat (`template_id` +
|
||||
/// `synced_template_version`) for a compact on-disk shape; [`to_agent`] folds it
|
||||
/// back into an [`AgentOrigin`].
|
||||
///
|
||||
/// Invariants:
|
||||
/// - `name` non-empty,
|
||||
/// - `md_path` relative and safe,
|
||||
/// - `synchronized == true` ⇒ `template_id.is_some() && synced_template_version.is_some()`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ManifestEntry {
|
||||
/// The agent this entry describes.
|
||||
pub agent_id: AgentId,
|
||||
/// Display name of the agent.
|
||||
pub name: String,
|
||||
/// Relative path of the agent's `.md`.
|
||||
pub md_path: String,
|
||||
/// Runtime profile reference.
|
||||
pub profile_id: ProfileId,
|
||||
/// Source template, if any.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub template_id: Option<TemplateId>,
|
||||
/// Whether the agent tracks its template.
|
||||
pub synchronized: bool,
|
||||
/// Template version recorded at the last sync.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub synced_template_version: Option<TemplateVersion>,
|
||||
}
|
||||
|
||||
impl ManifestEntry {
|
||||
/// Builds a validated manifest entry.
|
||||
///
|
||||
/// # Errors
|
||||
/// - [`DomainError::EmptyField`] if `name` is empty,
|
||||
/// - [`DomainError::PathNotRelativeSafe`] if `md_path` is absolute or has `..`,
|
||||
/// - [`DomainError::InconsistentManifest`] if `synchronized` is `true` while
|
||||
/// `template_id` or `synced_template_version` is missing.
|
||||
pub fn new(
|
||||
agent_id: AgentId,
|
||||
name: impl Into<String>,
|
||||
md_path: impl Into<String>,
|
||||
profile_id: ProfileId,
|
||||
template_id: Option<TemplateId>,
|
||||
synchronized: bool,
|
||||
synced_template_version: Option<TemplateVersion>,
|
||||
) -> Result<Self, DomainError> {
|
||||
let name = name.into();
|
||||
let md_path = md_path.into();
|
||||
crate::validation::non_empty(&name, "manifestEntry.name")?;
|
||||
crate::validation::relative_safe(&md_path)?;
|
||||
if synchronized && (template_id.is_none() || synced_template_version.is_none()) {
|
||||
return Err(DomainError::InconsistentManifest {
|
||||
reason: "synchronized entry requires templateId and syncedTemplateVersion"
|
||||
.to_string(),
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
agent_id,
|
||||
name,
|
||||
md_path,
|
||||
profile_id,
|
||||
template_id,
|
||||
synchronized,
|
||||
synced_template_version,
|
||||
})
|
||||
}
|
||||
|
||||
/// Projects an [`Agent`] into its manifest entry (flattening the origin).
|
||||
///
|
||||
/// Infallible: an [`Agent`] is already validated, and every entry invariant
|
||||
/// is implied by the agent's invariants.
|
||||
#[must_use]
|
||||
pub fn from_agent(agent: &Agent) -> Self {
|
||||
let (template_id, synced_template_version) = match &agent.origin {
|
||||
AgentOrigin::Scratch => (None, None),
|
||||
AgentOrigin::FromTemplate {
|
||||
template_id,
|
||||
synced_template_version,
|
||||
} => (Some(*template_id), Some(*synced_template_version)),
|
||||
};
|
||||
Self {
|
||||
agent_id: agent.id,
|
||||
name: agent.name.clone(),
|
||||
md_path: agent.context_path.clone(),
|
||||
profile_id: agent.profile_id,
|
||||
template_id,
|
||||
synchronized: agent.synchronized,
|
||||
synced_template_version,
|
||||
}
|
||||
}
|
||||
|
||||
/// Reconstructs the validated [`Agent`] this entry persists, folding the flat
|
||||
/// template link back into an [`AgentOrigin`].
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns a [`DomainError`] if the persisted fields violate an [`Agent`]
|
||||
/// invariant (e.g. a synchronized entry whose origin is not a template).
|
||||
pub fn to_agent(&self) -> Result<Agent, DomainError> {
|
||||
let origin = match (self.template_id, self.synced_template_version) {
|
||||
(Some(template_id), Some(synced_template_version)) => AgentOrigin::FromTemplate {
|
||||
template_id,
|
||||
synced_template_version,
|
||||
},
|
||||
_ => AgentOrigin::Scratch,
|
||||
};
|
||||
Agent::new(
|
||||
self.agent_id,
|
||||
self.name.clone(),
|
||||
self.md_path.clone(),
|
||||
self.profile_id,
|
||||
origin,
|
||||
self.synchronized,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// In-memory image of `.ideai/agents.json`.
|
||||
///
|
||||
/// Invariant enforced here: `md_path` values are unique across entries.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AgentManifest {
|
||||
/// Schema version of the manifest file.
|
||||
pub version: u32,
|
||||
/// Entries (one per project agent).
|
||||
#[serde(rename = "agents")]
|
||||
pub entries: Vec<ManifestEntry>,
|
||||
}
|
||||
|
||||
impl AgentManifest {
|
||||
/// Builds a validated manifest.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`DomainError::InconsistentManifest`] if two entries share the
|
||||
/// same `md_path`.
|
||||
pub fn new(version: u32, entries: Vec<ManifestEntry>) -> Result<Self, DomainError> {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for entry in &entries {
|
||||
if !seen.insert(entry.md_path.as_str()) {
|
||||
return Err(DomainError::InconsistentManifest {
|
||||
reason: format!("duplicate md_path `{}`", entry.md_path),
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(Self { version, entries })
|
||||
}
|
||||
}
|
||||
71
crates/domain/src/error.rs
Normal file
71
crates/domain/src/error.rs
Normal file
@ -0,0 +1,71 @@
|
||||
//! Domain-level validation errors.
|
||||
//!
|
||||
//! These errors are raised by validating constructors (`new`/`try_new`) when an
|
||||
//! invariant documented in `ARCHITECTURE.md` §3.2 is violated. They are pure
|
||||
//! data — no I/O, no platform coupling.
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
/// Error raised when an entity or value object invariant is violated at
|
||||
/// construction time.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Error)]
|
||||
pub enum DomainError {
|
||||
/// A required string field was empty.
|
||||
#[error("field `{field}` must not be empty")]
|
||||
EmptyField {
|
||||
/// Name of the offending field.
|
||||
field: &'static str,
|
||||
},
|
||||
|
||||
/// A path that must be absolute was relative.
|
||||
#[error("path `{path}` must be absolute")]
|
||||
PathNotAbsolute {
|
||||
/// The offending path.
|
||||
path: String,
|
||||
},
|
||||
|
||||
/// A path that must be relative was absolute, or escaped its root via `..`.
|
||||
#[error("path `{path}` must be relative and must not contain `..`")]
|
||||
PathNotRelativeSafe {
|
||||
/// The offending path.
|
||||
path: String,
|
||||
},
|
||||
|
||||
/// An environment variable name was not a valid identifier.
|
||||
#[error("`{value}` is not a valid environment variable identifier")]
|
||||
InvalidEnvVar {
|
||||
/// The offending value.
|
||||
value: String,
|
||||
},
|
||||
|
||||
/// An SSH port was outside the valid `1..=65535` range.
|
||||
#[error("ssh port must be in 1..=65535, got {port}")]
|
||||
InvalidPort {
|
||||
/// The offending port.
|
||||
port: u32,
|
||||
},
|
||||
|
||||
/// A PTY dimension was zero.
|
||||
#[error("pty size must have rows>0 and cols>0, got rows={rows} cols={cols}")]
|
||||
InvalidPtySize {
|
||||
/// Requested rows.
|
||||
rows: u16,
|
||||
/// Requested cols.
|
||||
cols: u16,
|
||||
},
|
||||
|
||||
/// `synchronized == true` requires an agent originating from a template.
|
||||
#[error("a synchronized agent must originate from a template")]
|
||||
SyncRequiresTemplate,
|
||||
|
||||
/// A manifest entry was inconsistent (e.g. synchronized without template metadata).
|
||||
#[error("manifest entry inconsistent: {reason}")]
|
||||
InconsistentManifest {
|
||||
/// Human-readable reason.
|
||||
reason: String,
|
||||
},
|
||||
|
||||
/// A generic invariant violation with an explanatory message.
|
||||
#[error("invariant violated: {0}")]
|
||||
Invariant(String),
|
||||
}
|
||||
79
crates/domain/src/events.rs
Normal file
79
crates/domain/src/events.rs
Normal file
@ -0,0 +1,79 @@
|
||||
//! Domain events published on the [`crate::ports::EventBus`] and relayed to the
|
||||
//! presentation layer (ARCHITECTURE §3.2).
|
||||
|
||||
use crate::ids::{AgentId, ProjectId, SessionId, TemplateId};
|
||||
use crate::template::TemplateVersion;
|
||||
|
||||
/// Events emitted by the domain/application as state changes occur.
|
||||
///
|
||||
/// Deliberately *not* `Serialize`/`Deserialize`: events are an in-process
|
||||
/// concern relayed to IPC by an infrastructure adapter, which owns the wire
|
||||
/// format. `PtyOutput` in particular is usually short-circuited to a Tauri
|
||||
/// channel rather than serialised here.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum DomainEvent {
|
||||
/// A project was created.
|
||||
ProjectCreated {
|
||||
/// The new project.
|
||||
project_id: ProjectId,
|
||||
},
|
||||
/// An agent was launched in a terminal.
|
||||
AgentLaunched {
|
||||
/// The agent.
|
||||
agent_id: AgentId,
|
||||
/// The session it runs in.
|
||||
session_id: SessionId,
|
||||
},
|
||||
/// An agent's process exited.
|
||||
AgentExited {
|
||||
/// The agent.
|
||||
agent_id: AgentId,
|
||||
/// Exit code.
|
||||
code: i32,
|
||||
},
|
||||
/// A template was updated (content changed, version bumped).
|
||||
TemplateUpdated {
|
||||
/// The template.
|
||||
template_id: TemplateId,
|
||||
/// New version.
|
||||
version: TemplateVersion,
|
||||
},
|
||||
/// A synchronized agent is behind its template.
|
||||
AgentDriftDetected {
|
||||
/// The drifting agent.
|
||||
agent_id: AgentId,
|
||||
/// Version the agent is currently at.
|
||||
from: TemplateVersion,
|
||||
/// Version available from the template.
|
||||
to: TemplateVersion,
|
||||
},
|
||||
/// A synchronized agent received its template update.
|
||||
AgentSynced {
|
||||
/// The agent.
|
||||
agent_id: AgentId,
|
||||
/// Version it was brought up to.
|
||||
to: TemplateVersion,
|
||||
},
|
||||
/// A tab's layout changed.
|
||||
LayoutChanged {
|
||||
/// The project whose layout changed.
|
||||
project_id: ProjectId,
|
||||
},
|
||||
/// A remote host connection was established.
|
||||
RemoteConnected {
|
||||
/// The project on that remote.
|
||||
project_id: ProjectId,
|
||||
},
|
||||
/// Git state for a project changed.
|
||||
GitStateChanged {
|
||||
/// The project.
|
||||
project_id: ProjectId,
|
||||
},
|
||||
/// Raw PTY output (usually routed to a dedicated channel, not this bus).
|
||||
PtyOutput {
|
||||
/// The session.
|
||||
session_id: SessionId,
|
||||
/// Output bytes.
|
||||
bytes: Vec<u8>,
|
||||
},
|
||||
}
|
||||
45
crates/domain/src/git.rs
Normal file
45
crates/domain/src/git.rs
Normal file
@ -0,0 +1,45 @@
|
||||
//! Git repository entity (domain state image).
|
||||
//!
|
||||
//! This is the *entity* describing the derived git state of a project. The
|
||||
//! git **port** (operations) lives in [`crate::ports`].
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::ids::ProjectId;
|
||||
use crate::project::ProjectPath;
|
||||
|
||||
/// Derived git state for a project, refreshed via the git port.
|
||||
///
|
||||
/// Invariant (operational, not enforced here): `root` contains — or will
|
||||
/// contain after `init` — a `.git` directory. This is verified by the
|
||||
/// infrastructure adapter, not by the pure domain.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GitRepository {
|
||||
/// Owning project.
|
||||
pub project_id: ProjectId,
|
||||
/// Repository root.
|
||||
pub root: ProjectPath,
|
||||
/// Current branch name, if the repo is on a branch.
|
||||
pub current_branch: Option<String>,
|
||||
/// Whether the working tree has uncommitted changes.
|
||||
pub is_dirty: bool,
|
||||
}
|
||||
|
||||
impl GitRepository {
|
||||
/// Builds a git-state snapshot.
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
project_id: ProjectId,
|
||||
root: ProjectPath,
|
||||
current_branch: Option<String>,
|
||||
is_dirty: bool,
|
||||
) -> Self {
|
||||
Self {
|
||||
project_id,
|
||||
root,
|
||||
current_branch,
|
||||
is_dirty,
|
||||
}
|
||||
}
|
||||
}
|
||||
90
crates/domain/src/ids.rs
Normal file
90
crates/domain/src/ids.rs
Normal file
@ -0,0 +1,90 @@
|
||||
//! Strongly-typed identifiers.
|
||||
//!
|
||||
//! Each identifier is a `newtype` around [`uuid::Uuid`]. Using distinct types
|
||||
//! per concept makes it impossible to pass, say, an [`AgentId`] where a
|
||||
//! [`ProjectId`] is expected (compile-time safety, SOLID/typing discipline).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
macro_rules! typed_id {
|
||||
($(#[$meta:meta])* $name:ident) => {
|
||||
$(#[$meta])*
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct $name(pub Uuid);
|
||||
|
||||
impl $name {
|
||||
/// Wraps an existing [`Uuid`].
|
||||
#[must_use]
|
||||
pub const fn from_uuid(id: Uuid) -> Self {
|
||||
Self(id)
|
||||
}
|
||||
|
||||
/// Generates a fresh random (v4) identifier.
|
||||
///
|
||||
/// Prefer injecting an [`crate::ports::IdGenerator`] in application
|
||||
/// code for determinism; this convenience exists for tests and the
|
||||
/// composition root.
|
||||
#[must_use]
|
||||
pub fn new_random() -> Self {
|
||||
Self(Uuid::new_v4())
|
||||
}
|
||||
|
||||
/// Returns the inner [`Uuid`].
|
||||
#[must_use]
|
||||
pub const fn as_uuid(&self) -> Uuid {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for $name {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Uuid> for $name {
|
||||
fn from(id: Uuid) -> Self {
|
||||
Self(id)
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
typed_id!(
|
||||
/// Identifies a [`crate::project::Project`].
|
||||
ProjectId
|
||||
);
|
||||
typed_id!(
|
||||
/// Identifies an [`crate::agent::Agent`].
|
||||
AgentId
|
||||
);
|
||||
typed_id!(
|
||||
/// Identifies an [`crate::template::AgentTemplate`].
|
||||
TemplateId
|
||||
);
|
||||
typed_id!(
|
||||
/// Identifies an [`crate::profile::AgentProfile`].
|
||||
ProfileId
|
||||
);
|
||||
typed_id!(
|
||||
/// Identifies a [`crate::terminal::TerminalSession`].
|
||||
SessionId
|
||||
);
|
||||
typed_id!(
|
||||
/// Identifies a [`crate::layout::WindowId`]-bearing OS window.
|
||||
WindowId
|
||||
);
|
||||
typed_id!(
|
||||
/// Identifies a [`crate::layout::Tab`].
|
||||
TabId
|
||||
);
|
||||
typed_id!(
|
||||
/// Identifies one named terminal layout within a project (L10/#4).
|
||||
LayoutId
|
||||
);
|
||||
typed_id!(
|
||||
/// Identifies a node in a [`crate::layout::LayoutTree`].
|
||||
NodeId
|
||||
);
|
||||
692
crates/domain/src/layout.rs
Normal file
692
crates/domain/src/layout.rs
Normal file
@ -0,0 +1,692 @@
|
||||
//! Pure, immutable terminal layout model (the "spreadsheet-like" recursive grid)
|
||||
//! and its operations.
|
||||
//!
|
||||
//! See ARCHITECTURE.md §7. The model is a recursive split/grid tree. All
|
||||
//! mutating operations are **pure functions** `&LayoutTree -> Result<LayoutTree,
|
||||
//! LayoutError>` returning a new tree, which makes them trivially testable and
|
||||
//! enables undo/redo at the application layer.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::ids::{AgentId, NodeId, SessionId, TabId, WindowId};
|
||||
|
||||
/// Direction of a [`SplitContainer`].
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Direction {
|
||||
/// Children laid out left→right (columns).
|
||||
Row,
|
||||
/// Children laid out top→bottom (rows).
|
||||
Column,
|
||||
}
|
||||
|
||||
/// A leaf cell hosting zero or one terminal session.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LeafCell {
|
||||
/// Node identifier.
|
||||
pub id: NodeId,
|
||||
/// The hosted session, if any (0 or 1).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub session: Option<SessionId>,
|
||||
/// The agent to launch automatically in this cell, if any.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub agent: Option<AgentId>,
|
||||
}
|
||||
|
||||
/// A weighted child within a [`SplitContainer`]. The `weight` is a *relative*
|
||||
/// resizable share; the UI normalises it for rendering.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WeightedChild {
|
||||
/// The child node.
|
||||
pub node: LayoutNode,
|
||||
/// Relative weight; invariant: `> 0`.
|
||||
pub weight: f32,
|
||||
}
|
||||
|
||||
/// A simple n-ary weighted split (rows or columns).
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SplitContainer {
|
||||
/// Node identifier.
|
||||
pub id: NodeId,
|
||||
/// Split direction.
|
||||
pub direction: Direction,
|
||||
/// Ordered children (left→right / top→bottom).
|
||||
pub children: Vec<WeightedChild>,
|
||||
}
|
||||
|
||||
/// A grid cell placement with spans (spreadsheet-style merging).
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GridCell {
|
||||
/// The hosted node (may itself be a split/grid — recursive).
|
||||
pub node: LayoutNode,
|
||||
/// Zero-based row index.
|
||||
pub row: u16,
|
||||
/// Zero-based column index.
|
||||
pub col: u16,
|
||||
/// Row span; invariant: `>= 1`.
|
||||
pub row_span: u16,
|
||||
/// Column span; invariant: `>= 1`.
|
||||
pub col_span: u16,
|
||||
}
|
||||
|
||||
/// A spreadsheet-like grid with per-column / per-row weights and span-based
|
||||
/// merging.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GridContainer {
|
||||
/// Node identifier.
|
||||
pub id: NodeId,
|
||||
/// Column widths (relative); length = number of columns.
|
||||
pub col_weights: Vec<f32>,
|
||||
/// Row heights (relative); length = number of rows.
|
||||
pub row_weights: Vec<f32>,
|
||||
/// Cell placements with spans.
|
||||
pub cells: Vec<GridCell>,
|
||||
}
|
||||
|
||||
/// A node in the layout tree.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", tag = "type", content = "node")]
|
||||
pub enum LayoutNode {
|
||||
/// A terminal-hosting leaf.
|
||||
Leaf(LeafCell),
|
||||
/// A weighted split.
|
||||
Split(SplitContainer),
|
||||
/// A spreadsheet-style grid.
|
||||
Grid(GridContainer),
|
||||
}
|
||||
|
||||
/// The root of a layout (one per tab).
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LayoutTree {
|
||||
/// Root node.
|
||||
pub root: LayoutNode,
|
||||
}
|
||||
|
||||
/// Errors produced by layout validation and operations.
|
||||
#[derive(Debug, Clone, PartialEq, Error)]
|
||||
pub enum LayoutError {
|
||||
/// A weight was not strictly positive.
|
||||
#[error("weight must be > 0, got {weight}")]
|
||||
NonPositiveWeight {
|
||||
/// Offending weight.
|
||||
weight: f32,
|
||||
},
|
||||
/// A split had no children.
|
||||
#[error("a split container must have at least one child")]
|
||||
EmptySplit,
|
||||
/// A grid span was less than one.
|
||||
#[error("grid span must be >= 1")]
|
||||
InvalidSpan,
|
||||
/// A grid cell extends beyond the grid bounds.
|
||||
#[error("grid cell at ({row},{col}) span ({row_span}x{col_span}) exceeds grid {rows}x{cols}")]
|
||||
SpanOutOfBounds {
|
||||
/// Cell row.
|
||||
row: u16,
|
||||
/// Cell column.
|
||||
col: u16,
|
||||
/// Row span.
|
||||
row_span: u16,
|
||||
/// Column span.
|
||||
col_span: u16,
|
||||
/// Grid rows.
|
||||
rows: u16,
|
||||
/// Grid cols.
|
||||
cols: u16,
|
||||
},
|
||||
/// Two grid cells overlap.
|
||||
#[error("grid cells overlap at ({row},{col})")]
|
||||
OverlappingCells {
|
||||
/// Row of the overlap.
|
||||
row: u16,
|
||||
/// Column of the overlap.
|
||||
col: u16,
|
||||
},
|
||||
/// Part of the grid surface is not covered by any cell.
|
||||
#[error("grid surface not fully covered: cell ({row},{col}) is empty")]
|
||||
UncoveredCell {
|
||||
/// Uncovered row.
|
||||
row: u16,
|
||||
/// Uncovered column.
|
||||
col: u16,
|
||||
},
|
||||
/// The same session appears in more than one leaf.
|
||||
#[error("session {0} appears in more than one leaf")]
|
||||
DuplicateSession(SessionId),
|
||||
/// A referenced node id was not found in the tree.
|
||||
#[error("node {0} not found")]
|
||||
NodeNotFound(NodeId),
|
||||
/// A merge/move spanned two distinct containers.
|
||||
#[error("operation cannot span two distinct containers")]
|
||||
CrossContainer,
|
||||
/// A referenced tab id was not found in any window.
|
||||
#[error("tab {0} not found")]
|
||||
TabNotFound(TabId),
|
||||
}
|
||||
|
||||
impl LayoutTree {
|
||||
/// Wraps a root node into a tree (without validation).
|
||||
#[must_use]
|
||||
pub fn new(root: LayoutNode) -> Self {
|
||||
Self { root }
|
||||
}
|
||||
|
||||
/// Convenience: a single-leaf tree.
|
||||
#[must_use]
|
||||
pub fn single(leaf: LeafCell) -> Self {
|
||||
Self {
|
||||
root: LayoutNode::Leaf(leaf),
|
||||
}
|
||||
}
|
||||
|
||||
/// Validates **all** layout invariants on the whole tree:
|
||||
/// positive weights, valid/non-overlapping/fully-covering grid spans, and
|
||||
/// at-most-one-leaf-per-session uniqueness.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns the first [`LayoutError`] encountered.
|
||||
pub fn validate(&self) -> Result<(), LayoutError> {
|
||||
let mut sessions = std::collections::HashSet::new();
|
||||
validate_node(&self.root, &mut sessions)
|
||||
}
|
||||
|
||||
/// Splits the leaf `target` into a [`SplitContainer`] with the original leaf
|
||||
/// and a new leaf `new_leaf`, in the given `direction` with equal weights.
|
||||
///
|
||||
/// Pure: returns a new validated tree.
|
||||
///
|
||||
/// # Errors
|
||||
/// - [`LayoutError::NodeNotFound`] if `target` is not a leaf in the tree,
|
||||
/// - any validation error of the resulting tree.
|
||||
pub fn split(
|
||||
&self,
|
||||
target: NodeId,
|
||||
direction: Direction,
|
||||
new_leaf: LeafCell,
|
||||
container_id: NodeId,
|
||||
) -> Result<Self, LayoutError> {
|
||||
let mut found = false;
|
||||
let root = map_node(&self.root, &mut |node| {
|
||||
if let LayoutNode::Leaf(leaf) = node {
|
||||
if leaf.id == target {
|
||||
found = true;
|
||||
return LayoutNode::Split(SplitContainer {
|
||||
id: container_id,
|
||||
direction,
|
||||
children: vec![
|
||||
WeightedChild {
|
||||
node: LayoutNode::Leaf(leaf.clone()),
|
||||
weight: 1.0,
|
||||
},
|
||||
WeightedChild {
|
||||
node: LayoutNode::Leaf(new_leaf.clone()),
|
||||
weight: 1.0,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
node.clone()
|
||||
});
|
||||
if !found {
|
||||
return Err(LayoutError::NodeNotFound(target));
|
||||
}
|
||||
let tree = Self { root };
|
||||
tree.validate()?;
|
||||
Ok(tree)
|
||||
}
|
||||
|
||||
/// Merges a [`SplitContainer`] identified by `container` back into a single
|
||||
/// node, keeping the child at `keep_index` and discarding the rest.
|
||||
///
|
||||
/// Pure: returns a new validated tree.
|
||||
///
|
||||
/// # Errors
|
||||
/// - [`LayoutError::NodeNotFound`] if `container` is not a split in the tree,
|
||||
/// - [`LayoutError::CrossContainer`] if `keep_index` is out of range,
|
||||
/// - any validation error of the resulting tree.
|
||||
pub fn merge(&self, container: NodeId, keep_index: usize) -> Result<Self, LayoutError> {
|
||||
let mut result: Result<(), LayoutError> = Ok(());
|
||||
let root = map_node(&self.root, &mut |node| {
|
||||
if let LayoutNode::Split(split) = node {
|
||||
if split.id == container {
|
||||
match split.children.get(keep_index) {
|
||||
Some(child) => return child.node.clone(),
|
||||
None => result = Err(LayoutError::CrossContainer),
|
||||
}
|
||||
}
|
||||
}
|
||||
node.clone()
|
||||
});
|
||||
result?;
|
||||
if root == self.root {
|
||||
return Err(LayoutError::NodeNotFound(container));
|
||||
}
|
||||
let tree = Self { root };
|
||||
tree.validate()?;
|
||||
Ok(tree)
|
||||
}
|
||||
|
||||
/// Resizes the children of a [`SplitContainer`] by assigning new `weights`.
|
||||
///
|
||||
/// Pure: returns a new validated tree.
|
||||
///
|
||||
/// # Errors
|
||||
/// - [`LayoutError::NodeNotFound`] if `container` is not a split,
|
||||
/// - [`LayoutError::CrossContainer`] if `weights.len()` differs from the
|
||||
/// child count,
|
||||
/// - [`LayoutError::NonPositiveWeight`] (via validation) if any weight ≤ 0.
|
||||
pub fn resize(&self, container: NodeId, weights: &[f32]) -> Result<Self, LayoutError> {
|
||||
let mut outcome: Option<Result<(), LayoutError>> = None;
|
||||
let root = map_node(&self.root, &mut |node| {
|
||||
if let LayoutNode::Split(split) = node {
|
||||
if split.id == container {
|
||||
if split.children.len() != weights.len() {
|
||||
outcome = Some(Err(LayoutError::CrossContainer));
|
||||
return node.clone();
|
||||
}
|
||||
let children = split
|
||||
.children
|
||||
.iter()
|
||||
.zip(weights.iter())
|
||||
.map(|(child, &w)| WeightedChild {
|
||||
node: child.node.clone(),
|
||||
weight: w,
|
||||
})
|
||||
.collect();
|
||||
outcome = Some(Ok(()));
|
||||
return LayoutNode::Split(SplitContainer {
|
||||
id: split.id,
|
||||
direction: split.direction,
|
||||
children,
|
||||
});
|
||||
}
|
||||
}
|
||||
node.clone()
|
||||
});
|
||||
match outcome {
|
||||
Some(Ok(())) => {
|
||||
let tree = Self { root };
|
||||
tree.validate()?;
|
||||
Ok(tree)
|
||||
}
|
||||
Some(Err(e)) => Err(e),
|
||||
None => Err(LayoutError::NodeNotFound(container)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Moves the session currently hosted by leaf `from` to leaf `to`.
|
||||
///
|
||||
/// `from` is left empty; `to` must currently be empty. This models dragging
|
||||
/// a terminal between cells without duplicating it. Pure: returns a new
|
||||
/// validated tree.
|
||||
///
|
||||
/// # Errors
|
||||
/// - [`LayoutError::NodeNotFound`] if either leaf is missing,
|
||||
/// - [`LayoutError::CrossContainer`] if `from` has no session or `to` is
|
||||
/// occupied,
|
||||
/// - any validation error of the resulting tree.
|
||||
pub fn move_session(&self, from: NodeId, to: NodeId) -> Result<Self, LayoutError> {
|
||||
let session = self.session_in_leaf(from)?;
|
||||
let Some(session) = session else {
|
||||
return Err(LayoutError::CrossContainer);
|
||||
};
|
||||
// Target must exist and be empty.
|
||||
match self.session_in_leaf(to)? {
|
||||
None => {}
|
||||
Some(_) => return Err(LayoutError::CrossContainer),
|
||||
}
|
||||
let root = map_node(&self.root, &mut |node| {
|
||||
if let LayoutNode::Leaf(leaf) = node {
|
||||
if leaf.id == from {
|
||||
return LayoutNode::Leaf(LeafCell {
|
||||
id: leaf.id,
|
||||
session: None,
|
||||
agent: leaf.agent,
|
||||
});
|
||||
}
|
||||
if leaf.id == to {
|
||||
return LayoutNode::Leaf(LeafCell {
|
||||
id: leaf.id,
|
||||
session: Some(session),
|
||||
agent: leaf.agent,
|
||||
});
|
||||
}
|
||||
}
|
||||
node.clone()
|
||||
});
|
||||
let tree = Self { root };
|
||||
tree.validate()?;
|
||||
Ok(tree)
|
||||
}
|
||||
|
||||
/// Attaches (or, with `None`, detaches) a [`SessionId`] to the leaf `target`.
|
||||
///
|
||||
/// This is the bridge between the layout and the terminal layer (L3/L4): when
|
||||
/// [`crate::terminal::TerminalSession`] is opened for a cell, the application
|
||||
/// records its id in the hosting leaf with this pure operation.
|
||||
///
|
||||
/// Pure: returns a new validated tree.
|
||||
///
|
||||
/// # Errors
|
||||
/// - [`LayoutError::NodeNotFound`] if `target` is not a leaf in the tree,
|
||||
/// - [`LayoutError::DuplicateSession`] (via validation) if `session` is already
|
||||
/// hosted by another leaf.
|
||||
pub fn set_session(
|
||||
&self,
|
||||
target: NodeId,
|
||||
session: Option<SessionId>,
|
||||
) -> Result<Self, LayoutError> {
|
||||
let mut found = false;
|
||||
let root = map_node(&self.root, &mut |node| {
|
||||
if let LayoutNode::Leaf(leaf) = node {
|
||||
if leaf.id == target {
|
||||
found = true;
|
||||
return LayoutNode::Leaf(LeafCell {
|
||||
id: leaf.id,
|
||||
session,
|
||||
agent: leaf.agent,
|
||||
});
|
||||
}
|
||||
}
|
||||
node.clone()
|
||||
});
|
||||
if !found {
|
||||
return Err(LayoutError::NodeNotFound(target));
|
||||
}
|
||||
let tree = Self { root };
|
||||
tree.validate()?;
|
||||
Ok(tree)
|
||||
}
|
||||
|
||||
/// Attaches (or, with `None`, detaches) an [`AgentId`] to the leaf `target`.
|
||||
///
|
||||
/// Records which agent should be auto-launched in the cell (feature #3).
|
||||
///
|
||||
/// Pure: returns a new validated tree.
|
||||
///
|
||||
/// # Errors
|
||||
/// - [`LayoutError::NodeNotFound`] if `target` is not a leaf in the tree.
|
||||
pub fn set_cell_agent(
|
||||
&self,
|
||||
target: NodeId,
|
||||
agent: Option<AgentId>,
|
||||
) -> Result<Self, LayoutError> {
|
||||
let mut found = false;
|
||||
let root = map_node(&self.root, &mut |node| {
|
||||
if let LayoutNode::Leaf(leaf) = node {
|
||||
if leaf.id == target {
|
||||
found = true;
|
||||
return LayoutNode::Leaf(LeafCell {
|
||||
id: leaf.id,
|
||||
session: leaf.session,
|
||||
agent,
|
||||
});
|
||||
}
|
||||
}
|
||||
node.clone()
|
||||
});
|
||||
if !found {
|
||||
return Err(LayoutError::NodeNotFound(target));
|
||||
}
|
||||
let tree = Self { root };
|
||||
tree.validate()?;
|
||||
Ok(tree)
|
||||
}
|
||||
|
||||
/// Returns `Ok(Some(session))` / `Ok(None)` for the session held by the leaf
|
||||
/// `id`, or [`LayoutError::NodeNotFound`] if no such leaf exists.
|
||||
fn session_in_leaf(&self, id: NodeId) -> Result<Option<SessionId>, LayoutError> {
|
||||
fn find(node: &LayoutNode, id: NodeId) -> Option<Option<SessionId>> {
|
||||
match node {
|
||||
LayoutNode::Leaf(leaf) if leaf.id == id => Some(leaf.session),
|
||||
LayoutNode::Leaf(_) => None,
|
||||
LayoutNode::Split(split) => {
|
||||
split.children.iter().find_map(|c| find(&c.node, id))
|
||||
}
|
||||
LayoutNode::Grid(grid) => grid.cells.iter().find_map(|c| find(&c.node, id)),
|
||||
}
|
||||
}
|
||||
find(&self.root, id).ok_or(LayoutError::NodeNotFound(id))
|
||||
}
|
||||
}
|
||||
|
||||
/// Recursively rebuilds a node, applying `f` to every node (post-order: `f` sees
|
||||
/// already-rebuilt children).
|
||||
fn map_node(node: &LayoutNode, f: &mut impl FnMut(&LayoutNode) -> LayoutNode) -> LayoutNode {
|
||||
let rebuilt = match node {
|
||||
LayoutNode::Leaf(_) => node.clone(),
|
||||
LayoutNode::Split(split) => LayoutNode::Split(SplitContainer {
|
||||
id: split.id,
|
||||
direction: split.direction,
|
||||
children: split
|
||||
.children
|
||||
.iter()
|
||||
.map(|c| WeightedChild {
|
||||
node: map_node(&c.node, f),
|
||||
weight: c.weight,
|
||||
})
|
||||
.collect(),
|
||||
}),
|
||||
LayoutNode::Grid(grid) => LayoutNode::Grid(GridContainer {
|
||||
id: grid.id,
|
||||
col_weights: grid.col_weights.clone(),
|
||||
row_weights: grid.row_weights.clone(),
|
||||
cells: grid
|
||||
.cells
|
||||
.iter()
|
||||
.map(|c| GridCell {
|
||||
node: map_node(&c.node, f),
|
||||
row: c.row,
|
||||
col: c.col,
|
||||
row_span: c.row_span,
|
||||
col_span: c.col_span,
|
||||
})
|
||||
.collect(),
|
||||
}),
|
||||
};
|
||||
f(&rebuilt)
|
||||
}
|
||||
|
||||
/// Recursive validation of a single node, accumulating seen sessions to enforce
|
||||
/// global uniqueness.
|
||||
fn validate_node(
|
||||
node: &LayoutNode,
|
||||
sessions: &mut std::collections::HashSet<SessionId>,
|
||||
) -> Result<(), LayoutError> {
|
||||
match node {
|
||||
LayoutNode::Leaf(leaf) => {
|
||||
if let Some(session) = leaf.session {
|
||||
if !sessions.insert(session) {
|
||||
return Err(LayoutError::DuplicateSession(session));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
LayoutNode::Split(split) => {
|
||||
if split.children.is_empty() {
|
||||
return Err(LayoutError::EmptySplit);
|
||||
}
|
||||
for child in &split.children {
|
||||
if child.weight <= 0.0 {
|
||||
return Err(LayoutError::NonPositiveWeight {
|
||||
weight: child.weight,
|
||||
});
|
||||
}
|
||||
validate_node(&child.node, sessions)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
LayoutNode::Grid(grid) => validate_grid(grid, sessions),
|
||||
}
|
||||
}
|
||||
|
||||
/// Validates a grid: positive weights, in-bounds spans, no overlaps, full
|
||||
/// coverage, and recursion into cell contents.
|
||||
fn validate_grid(
|
||||
grid: &GridContainer,
|
||||
sessions: &mut std::collections::HashSet<SessionId>,
|
||||
) -> Result<(), LayoutError> {
|
||||
for &w in grid.col_weights.iter().chain(grid.row_weights.iter()) {
|
||||
if w <= 0.0 {
|
||||
return Err(LayoutError::NonPositiveWeight { weight: w });
|
||||
}
|
||||
}
|
||||
let rows = grid.row_weights.len();
|
||||
let cols = grid.col_weights.len();
|
||||
// Occupancy matrix to detect overlaps and gaps.
|
||||
let mut occupied = vec![false; rows * cols];
|
||||
for cell in &grid.cells {
|
||||
if cell.row_span < 1 || cell.col_span < 1 {
|
||||
return Err(LayoutError::InvalidSpan);
|
||||
}
|
||||
let row_end = cell.row as usize + cell.row_span as usize;
|
||||
let col_end = cell.col as usize + cell.col_span as usize;
|
||||
if row_end > rows || col_end > cols {
|
||||
return Err(LayoutError::SpanOutOfBounds {
|
||||
row: cell.row,
|
||||
col: cell.col,
|
||||
row_span: cell.row_span,
|
||||
col_span: cell.col_span,
|
||||
rows: rows as u16,
|
||||
cols: cols as u16,
|
||||
});
|
||||
}
|
||||
for r in cell.row as usize..row_end {
|
||||
for c in cell.col as usize..col_end {
|
||||
let idx = r * cols + c;
|
||||
if occupied[idx] {
|
||||
return Err(LayoutError::OverlappingCells {
|
||||
row: r as u16,
|
||||
col: c as u16,
|
||||
});
|
||||
}
|
||||
occupied[idx] = true;
|
||||
}
|
||||
}
|
||||
validate_node(&cell.node, sessions)?;
|
||||
}
|
||||
// Full coverage.
|
||||
for r in 0..rows {
|
||||
for c in 0..cols {
|
||||
if !occupied[r * cols + c] {
|
||||
return Err(LayoutError::UncoveredCell {
|
||||
row: r as u16,
|
||||
col: c as u16,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Window / Tab / Workspace — persisted presentation entities (ARCHITECTURE §3.2)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// An open tab, bound 1:1 to a project.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Tab {
|
||||
/// Tab identifier.
|
||||
pub id: TabId,
|
||||
/// The project shown in this tab.
|
||||
pub project_id: crate::ids::ProjectId,
|
||||
/// The terminal layout of this tab.
|
||||
pub layout: LayoutTree,
|
||||
}
|
||||
|
||||
/// An OS window holding one or more tabs.
|
||||
///
|
||||
/// Invariant: a non-closed window holds at least one tab (see [`Window::new`]).
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Window {
|
||||
/// Window identifier.
|
||||
pub id: WindowId,
|
||||
/// Tabs in this window.
|
||||
pub tabs: Vec<Tab>,
|
||||
/// Currently active tab.
|
||||
pub active_tab: TabId,
|
||||
}
|
||||
|
||||
impl Window {
|
||||
/// Builds a window, enforcing the "≥ 1 tab" invariant and that `active_tab`
|
||||
/// refers to one of the tabs.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`LayoutError::CrossContainer`] if `tabs` is empty or `active_tab`
|
||||
/// is not present.
|
||||
pub fn new(id: WindowId, tabs: Vec<Tab>, active_tab: TabId) -> Result<Self, LayoutError> {
|
||||
if tabs.is_empty() || !tabs.iter().any(|t| t.id == active_tab) {
|
||||
return Err(LayoutError::CrossContainer);
|
||||
}
|
||||
Ok(Self {
|
||||
id,
|
||||
tabs,
|
||||
active_tab,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The set of windows for a user session.
|
||||
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Workspace {
|
||||
/// All open OS windows.
|
||||
pub windows: Vec<Window>,
|
||||
}
|
||||
|
||||
impl Workspace {
|
||||
/// Detaches a tab into a brand-new window (ARCHITECTURE §10, L10).
|
||||
///
|
||||
/// A **pure** transformation returning the next workspace state — the tab is
|
||||
/// *moved*, never duplicated (the "a project is open in exactly one tab"
|
||||
/// invariant). If the source window becomes empty it is removed; otherwise, if
|
||||
/// the detached tab was the active one, the source's active tab falls back to
|
||||
/// its first remaining tab. The new window holds the tab and makes it active.
|
||||
///
|
||||
/// # Errors
|
||||
/// - [`LayoutError::TabNotFound`] if no window contains `tab_id`,
|
||||
/// - propagates [`Window::new`] invariants for the created window.
|
||||
pub fn move_tab_to_new_window(
|
||||
&self,
|
||||
tab_id: TabId,
|
||||
new_window_id: WindowId,
|
||||
) -> Result<Self, LayoutError> {
|
||||
let mut windows = self.windows.clone();
|
||||
|
||||
// Locate the (window, tab) holding `tab_id`.
|
||||
let (wi, ti) = windows
|
||||
.iter()
|
||||
.enumerate()
|
||||
.find_map(|(wi, w)| {
|
||||
w.tabs
|
||||
.iter()
|
||||
.position(|t| t.id == tab_id)
|
||||
.map(|ti| (wi, ti))
|
||||
})
|
||||
.ok_or(LayoutError::TabNotFound(tab_id))?;
|
||||
|
||||
let tab = windows[wi].tabs.remove(ti);
|
||||
|
||||
if windows[wi].tabs.is_empty() {
|
||||
// The source window is now empty: drop it (windows hold ≥ 1 tab).
|
||||
windows.remove(wi);
|
||||
} else if windows[wi].active_tab == tab_id {
|
||||
// The moved tab was active: fall back to the first remaining tab.
|
||||
windows[wi].active_tab = windows[wi].tabs[0].id;
|
||||
}
|
||||
|
||||
let detached = Window::new(new_window_id, vec![tab.clone()], tab.id)?;
|
||||
windows.push(detached);
|
||||
|
||||
Ok(Self { windows })
|
||||
}
|
||||
}
|
||||
88
crates/domain/src/lib.rs
Normal file
88
crates/domain/src/lib.rs
Normal file
@ -0,0 +1,88 @@
|
||||
//! # IdeA — Domain layer
|
||||
//!
|
||||
//! The **pure** hexagonal core (ARCHITECTURE.md §1.4, §3, §4, §7). It contains:
|
||||
//!
|
||||
//! - **Entities & value objects** with invariants enforced by validating
|
||||
//! constructors (`new`/`try_new` returning `Result`),
|
||||
//! - the **pure layout logic** (`split`/`merge`/`resize`/`move` as immutable
|
||||
//! `&LayoutTree -> Result<LayoutTree, LayoutError>` functions),
|
||||
//! - **ports** (traits) the infrastructure implements,
|
||||
//! - **domain events** and **errors**.
|
||||
//!
|
||||
//! ## Dependency rule
|
||||
//!
|
||||
//! This crate depends on **no I/O**: no `tokio`, no `std::fs`, no
|
||||
//! `std::process`, no `git2`/`portable-pty`/`russh`. The only third-party
|
||||
//! dependencies are `uuid`, `serde` (allowed solely to derive (de)serialisation
|
||||
//! of *persisted* domain types — a metier format constraint, not I/O),
|
||||
//! `thiserror`, and `async-trait`.
|
||||
//!
|
||||
//! ## Async strategy for ports
|
||||
//!
|
||||
//! I/O-touching ports (`PtyPort`, `FileSystem`, `ProcessSpawner`, `RemoteHost`,
|
||||
//! the stores, `GitPort`) are `#[async_trait]`. They are injected as
|
||||
//! `Arc<dyn Port>` trait objects at the composition root, which native
|
||||
//! `async fn`-in-trait does not yet support dyn-compatibly without boxing;
|
||||
//! `async_trait` boxes the returned future and keeps the ports object-safe.
|
||||
//! Non-blocking ports (`Clock`, `IdGenerator`, `EventBus`, `AgentRuntime`)
|
||||
//! remain plain synchronous traits. See [`ports`] for details.
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
#![warn(missing_docs)]
|
||||
|
||||
pub mod agent;
|
||||
pub mod error;
|
||||
pub mod events;
|
||||
pub mod git;
|
||||
pub mod ids;
|
||||
pub mod layout;
|
||||
pub mod markdown;
|
||||
pub mod ports;
|
||||
pub mod profile;
|
||||
pub mod project;
|
||||
pub mod remote;
|
||||
pub mod template;
|
||||
pub mod terminal;
|
||||
|
||||
mod validation;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Curated re-exports for ergonomic downstream use.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub use error::DomainError;
|
||||
|
||||
pub use ids::{
|
||||
AgentId, LayoutId, NodeId, ProfileId, ProjectId, SessionId, TabId, TemplateId, WindowId,
|
||||
};
|
||||
|
||||
pub use project::{Project, ProjectPath};
|
||||
|
||||
pub use agent::{Agent, AgentManifest, AgentOrigin, ManifestEntry};
|
||||
|
||||
pub use template::{AgentTemplate, TemplateVersion};
|
||||
|
||||
pub use profile::{AgentProfile, ContextInjection};
|
||||
|
||||
pub use markdown::MarkdownDoc;
|
||||
|
||||
pub use remote::{RemoteKind, RemoteRef, SshAuth};
|
||||
|
||||
pub use terminal::{PtySize, SessionKind, SessionStatus, TerminalSession};
|
||||
|
||||
pub use git::GitRepository;
|
||||
|
||||
pub use layout::{
|
||||
Direction, GridCell, GridContainer, LayoutError, LayoutNode, LayoutTree, LeafCell,
|
||||
SplitContainer, Tab, WeightedChild, Window, Workspace,
|
||||
};
|
||||
|
||||
pub use events::DomainEvent;
|
||||
|
||||
pub use ports::{
|
||||
AgentContextStore, AgentRuntime, Clock, ContextInjectionPlan, DirEntry, EventBus, EventStream,
|
||||
ExitStatus, FileSystem, FsError, GitCommitInfo, GitError, GitFileStatus, GitPort, GraphCommit,
|
||||
IdGenerator, Output, OutputStream, PreparedContext, ProcessError, ProcessSpawner, ProfileStore,
|
||||
ProjectStore, PtyError, PtyHandle, PtyPort, RemoteError, RemoteHost, RemotePath, RuntimeError,
|
||||
SpawnSpec, StoreError, TemplateStore,
|
||||
};
|
||||
44
crates/domain/src/markdown.rs
Normal file
44
crates/domain/src/markdown.rs
Normal file
@ -0,0 +1,44 @@
|
||||
//! Markdown content value object.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A Markdown document — the content of an agent context (`.md`) or a template.
|
||||
///
|
||||
/// This is a thin newtype: the domain treats Markdown as opaque text and does
|
||||
/// not parse it. It exists to give the content a meaningful type at API
|
||||
/// boundaries (vs. a bare `String`).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct MarkdownDoc(String);
|
||||
|
||||
impl MarkdownDoc {
|
||||
/// Wraps raw Markdown content (empty content is permitted).
|
||||
#[must_use]
|
||||
pub fn new(content: impl Into<String>) -> Self {
|
||||
Self(content.into())
|
||||
}
|
||||
|
||||
/// Returns the content as a string slice.
|
||||
#[must_use]
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
|
||||
/// Consumes the document, returning its content.
|
||||
#[must_use]
|
||||
pub fn into_string(self) -> String {
|
||||
self.0
|
||||
}
|
||||
|
||||
/// Returns `true` if the document is empty.
|
||||
#[must_use]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.0.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for MarkdownDoc {
|
||||
fn from(s: String) -> Self {
|
||||
Self(s)
|
||||
}
|
||||
}
|
||||
663
crates/domain/src/ports.rs
Normal file
663
crates/domain/src/ports.rs
Normal file
@ -0,0 +1,663 @@
|
||||
//! Ports (traits) — the boundary the domain defines and the infrastructure
|
||||
//! implements (the **D** of SOLID, materialised).
|
||||
//!
|
||||
//! # Async decision
|
||||
//!
|
||||
//! Ports that touch the outside world (PTY, filesystem, process, stores, git,
|
||||
//! remote connect) are inherently asynchronous in every realistic adapter
|
||||
//! (tokio fs/process, russh, sftp). We therefore make those traits `async` via
|
||||
//! [`async_trait`]. The rationale for `#[async_trait]` over native
|
||||
//! `async fn` in traits / `-> impl Future`:
|
||||
//!
|
||||
//! - These ports are consumed as **trait objects** (`Arc<dyn FileSystem>`,
|
||||
//! injected at the composition root). Native `async fn` in traits is not yet
|
||||
//! dyn-compatible without boxing, so `async_trait` (which boxes the future)
|
||||
//! is the pragmatic, stable choice and keeps the call sites object-safe.
|
||||
//! - It keeps signatures readable and uniform across all adapters.
|
||||
//!
|
||||
//! Purely synchronous, non-blocking ports ([`Clock`], [`IdGenerator`],
|
||||
//! [`EventBus`], the CPU-bound part of [`AgentRuntime`]) stay plain `fn` — no
|
||||
//! need to pay the boxing cost.
|
||||
//!
|
||||
//! Each port is **fine-grained** (Interface Segregation): `FileSystem`,
|
||||
//! `PtyPort`, `ProcessSpawner` are separate, never a `System` god-trait.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::agent::AgentManifest;
|
||||
use crate::events::DomainEvent;
|
||||
use crate::ids::{AgentId, SessionId};
|
||||
use crate::markdown::MarkdownDoc;
|
||||
use crate::profile::AgentProfile;
|
||||
use crate::project::{Project, ProjectPath};
|
||||
use crate::remote::RemoteKind;
|
||||
use crate::template::AgentTemplate;
|
||||
use crate::terminal::PtySize;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Support value types shared across ports
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// How the `.md` context should be delivered to a spawned process, resolved
|
||||
/// from a profile's [`crate::profile::ContextInjection`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ContextInjectionPlan {
|
||||
/// Materialise the context at a (relative) file path before launch.
|
||||
File {
|
||||
/// Relative target path inside the cwd.
|
||||
target: String,
|
||||
},
|
||||
/// Append the given already-rendered arguments to the command line.
|
||||
Args {
|
||||
/// Extra arguments carrying the context path.
|
||||
args: Vec<String>,
|
||||
},
|
||||
/// Pipe the content on stdin.
|
||||
Stdin,
|
||||
/// Provide the content (or its path) via an environment variable.
|
||||
Env {
|
||||
/// Variable name.
|
||||
var: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// A fully-resolved process invocation: command, args, cwd, environment, and the
|
||||
/// plan for delivering the agent context.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SpawnSpec {
|
||||
/// Executable to run.
|
||||
pub command: String,
|
||||
/// Arguments.
|
||||
pub args: Vec<String>,
|
||||
/// Working directory.
|
||||
pub cwd: ProjectPath,
|
||||
/// Extra environment variables.
|
||||
pub env: Vec<(String, String)>,
|
||||
/// How the context is injected, if any.
|
||||
pub context_plan: Option<ContextInjectionPlan>,
|
||||
}
|
||||
|
||||
/// The agent context prepared for injection (content + the on-disk path it maps
|
||||
/// to within the project).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PreparedContext {
|
||||
/// Rendered Markdown content.
|
||||
pub content: MarkdownDoc,
|
||||
/// Relative path of the `.md` inside the project.
|
||||
pub relative_path: String,
|
||||
}
|
||||
|
||||
/// An opaque handle to a live PTY, owned by the adapter.
|
||||
///
|
||||
/// The domain only needs an identity to address the PTY in subsequent calls;
|
||||
/// the actual OS handle lives in infrastructure.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub struct PtyHandle {
|
||||
/// The session this PTY backs.
|
||||
pub session_id: SessionId,
|
||||
}
|
||||
|
||||
/// Exit status of a process.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ExitStatus {
|
||||
/// Exit code (`None` if terminated by a signal).
|
||||
pub code: Option<i32>,
|
||||
}
|
||||
|
||||
/// Captured output of a non-interactive process run.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Output {
|
||||
/// Exit status.
|
||||
pub status: ExitStatus,
|
||||
/// Captured stdout.
|
||||
pub stdout: Vec<u8>,
|
||||
/// Captured stderr.
|
||||
pub stderr: Vec<u8>,
|
||||
}
|
||||
|
||||
/// A location-neutral path used by [`FileSystem`] (local, SFTP, or WSL).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub struct RemotePath(pub String);
|
||||
|
||||
impl RemotePath {
|
||||
/// Wraps a raw path.
|
||||
#[must_use]
|
||||
pub fn new(p: impl Into<String>) -> Self {
|
||||
Self(p.into())
|
||||
}
|
||||
|
||||
/// Returns the path as a string slice.
|
||||
#[must_use]
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
/// A single directory entry returned by [`FileSystem::list`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DirEntry {
|
||||
/// Entry name (basename).
|
||||
pub name: String,
|
||||
/// Whether the entry is a directory.
|
||||
pub is_dir: bool,
|
||||
}
|
||||
|
||||
/// An owned, boxed stream of PTY output chunks.
|
||||
///
|
||||
/// Concrete adapters decide the underlying transport; the domain only sees a
|
||||
/// dynamically-dispatched iterator of byte chunks.
|
||||
pub type OutputStream = Box<dyn Iterator<Item = Vec<u8>> + Send>;
|
||||
|
||||
/// A boxed stream of domain events, returned by [`EventBus::subscribe`].
|
||||
pub type EventStream = Box<dyn Iterator<Item = DomainEvent> + Send>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per-port error types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Errors from [`AgentRuntime`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Error)]
|
||||
pub enum RuntimeError {
|
||||
/// The configured command could not be resolved/detected.
|
||||
#[error("agent runtime: {0}")]
|
||||
Detection(String),
|
||||
/// The invocation could not be prepared (bad profile, etc.).
|
||||
#[error("agent runtime: invalid invocation: {0}")]
|
||||
Invocation(String),
|
||||
}
|
||||
|
||||
/// Errors from [`PtyPort`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Error)]
|
||||
pub enum PtyError {
|
||||
/// Failed to spawn the PTY.
|
||||
#[error("pty spawn failed: {0}")]
|
||||
Spawn(String),
|
||||
/// Read/write failure.
|
||||
#[error("pty io failed: {0}")]
|
||||
Io(String),
|
||||
/// The handle referred to no live PTY.
|
||||
#[error("pty handle not found")]
|
||||
NotFound,
|
||||
}
|
||||
|
||||
/// Errors from [`ProcessSpawner`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Error)]
|
||||
pub enum ProcessError {
|
||||
/// The process could not be started.
|
||||
#[error("process spawn failed: {0}")]
|
||||
Spawn(String),
|
||||
/// I/O failure while running.
|
||||
#[error("process io failed: {0}")]
|
||||
Io(String),
|
||||
}
|
||||
|
||||
/// Errors from [`FileSystem`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Error)]
|
||||
pub enum FsError {
|
||||
/// Path not found.
|
||||
#[error("path not found: {0}")]
|
||||
NotFound(String),
|
||||
/// Permission denied.
|
||||
#[error("permission denied: {0}")]
|
||||
PermissionDenied(String),
|
||||
/// Other I/O error.
|
||||
#[error("filesystem io failed: {0}")]
|
||||
Io(String),
|
||||
}
|
||||
|
||||
/// Errors from persistence stores ([`TemplateStore`], [`ProjectStore`],
|
||||
/// [`AgentContextStore`]).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Error)]
|
||||
pub enum StoreError {
|
||||
/// The requested item was not found.
|
||||
#[error("not found")]
|
||||
NotFound,
|
||||
/// (De)serialisation failed.
|
||||
#[error("serialization failed: {0}")]
|
||||
Serialization(String),
|
||||
/// Underlying I/O error.
|
||||
#[error("store io failed: {0}")]
|
||||
Io(String),
|
||||
}
|
||||
|
||||
/// Errors from [`RemoteHost`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Error)]
|
||||
pub enum RemoteError {
|
||||
/// Connection failed.
|
||||
#[error("remote connection failed: {0}")]
|
||||
Connection(String),
|
||||
/// Authentication failed.
|
||||
#[error("remote authentication failed: {0}")]
|
||||
Auth(String),
|
||||
}
|
||||
|
||||
/// Errors from the git [`GitPort`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Error)]
|
||||
pub enum GitError {
|
||||
/// The repository was not found / not initialised.
|
||||
#[error("git repository not found")]
|
||||
NotFound,
|
||||
/// A git operation failed.
|
||||
#[error("git operation failed: {0}")]
|
||||
Operation(String),
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Git port support types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Status of a single path in the working tree.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct GitFileStatus {
|
||||
/// Path relative to the repo root.
|
||||
pub path: String,
|
||||
/// Whether the change is staged.
|
||||
pub staged: bool,
|
||||
}
|
||||
|
||||
/// A commit summary.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct GitCommitInfo {
|
||||
/// Commit hash.
|
||||
pub hash: String,
|
||||
/// Commit message summary.
|
||||
pub summary: String,
|
||||
}
|
||||
|
||||
/// A commit enriched for graph display (DAG edges + refs + author + time).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct GraphCommit {
|
||||
/// Full commit hash (hex string).
|
||||
pub hash: String,
|
||||
/// First line of the commit message.
|
||||
pub summary: String,
|
||||
/// Parent commit hashes (0 for root, ≥2 for merges).
|
||||
pub parents: Vec<String>,
|
||||
/// Human-readable ref labels pointing at this commit
|
||||
/// (e.g. `"main"`, `"tag: v1.0"`).
|
||||
pub refs: Vec<String>,
|
||||
/// Author name (empty string when unavailable).
|
||||
pub author: String,
|
||||
/// Author timestamp in Unix seconds.
|
||||
pub timestamp: i64,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Ports
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Launch/drive an AI CLI according to an [`AgentProfile`], handling context
|
||||
/// injection. CPU-bound and synchronous — kept plain `fn`.
|
||||
pub trait AgentRuntime: Send + Sync {
|
||||
/// Detects whether the profile's command is available.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`RuntimeError`] on detection failure.
|
||||
fn detect(&self, profile: &AgentProfile) -> Result<bool, RuntimeError>;
|
||||
|
||||
/// Builds a [`SpawnSpec`] (command + args + injection plan) for launching
|
||||
/// the agent in `cwd` with the prepared context.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`RuntimeError`] if the invocation cannot be prepared.
|
||||
fn prepare_invocation(
|
||||
&self,
|
||||
profile: &AgentProfile,
|
||||
ctx: &PreparedContext,
|
||||
cwd: &ProjectPath,
|
||||
) -> Result<SpawnSpec, RuntimeError>;
|
||||
}
|
||||
|
||||
/// Open and drive pseudo-terminals.
|
||||
#[async_trait]
|
||||
pub trait PtyPort: Send + Sync {
|
||||
/// Spawns a PTY running `spec` at the given `size`.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`PtyError`] on failure.
|
||||
async fn spawn(&self, spec: SpawnSpec, size: PtySize) -> Result<PtyHandle, PtyError>;
|
||||
|
||||
/// Writes bytes to the PTY.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`PtyError`] on failure.
|
||||
fn write(&self, handle: &PtyHandle, data: &[u8]) -> Result<(), PtyError>;
|
||||
|
||||
/// Resizes the PTY.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`PtyError`] on failure.
|
||||
fn resize(&self, handle: &PtyHandle, size: PtySize) -> Result<(), PtyError>;
|
||||
|
||||
/// Subscribes to the PTY's byte output stream.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`PtyError`] if the handle is unknown.
|
||||
fn subscribe_output(&self, handle: &PtyHandle) -> Result<OutputStream, PtyError>;
|
||||
|
||||
/// Kills the PTY's process, returning its exit status.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`PtyError`] on failure.
|
||||
async fn kill(&self, handle: &PtyHandle) -> Result<ExitStatus, PtyError>;
|
||||
}
|
||||
|
||||
/// Run a non-interactive process and capture its output.
|
||||
#[async_trait]
|
||||
pub trait ProcessSpawner: Send + Sync {
|
||||
/// Runs `spec` to completion.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`ProcessError`] on failure.
|
||||
async fn run(&self, spec: SpawnSpec) -> Result<Output, ProcessError>;
|
||||
}
|
||||
|
||||
/// Location-neutral filesystem access.
|
||||
#[async_trait]
|
||||
pub trait FileSystem: Send + Sync {
|
||||
/// Reads a file.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`FsError`] on failure.
|
||||
async fn read(&self, path: &RemotePath) -> Result<Vec<u8>, FsError>;
|
||||
|
||||
/// Writes a file (creating or truncating).
|
||||
///
|
||||
/// # Errors
|
||||
/// [`FsError`] on failure.
|
||||
async fn write(&self, path: &RemotePath, data: &[u8]) -> Result<(), FsError>;
|
||||
|
||||
/// Returns whether the path exists.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`FsError`] on failure.
|
||||
async fn exists(&self, path: &RemotePath) -> Result<bool, FsError>;
|
||||
|
||||
/// Creates a directory and all missing parents.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`FsError`] on failure.
|
||||
async fn create_dir_all(&self, path: &RemotePath) -> Result<(), FsError>;
|
||||
|
||||
/// Lists the entries of a directory.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`FsError`] on failure.
|
||||
async fn list(&self, path: &RemotePath) -> Result<Vec<DirEntry>, FsError>;
|
||||
|
||||
/// Creates a symbolic link `dst` pointing at `src`.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`FsError`] on failure.
|
||||
async fn symlink(&self, src: &RemotePath, dst: &RemotePath) -> Result<(), FsError>;
|
||||
}
|
||||
|
||||
/// Strategy abstracting *where* execution happens (local / SSH / WSL). Acts as a
|
||||
/// factory for the location-appropriate fine-grained ports.
|
||||
#[async_trait]
|
||||
pub trait RemoteHost: Send + Sync {
|
||||
/// The kind of this host.
|
||||
fn kind(&self) -> RemoteKind;
|
||||
|
||||
/// Establishes the connection (no-op for local).
|
||||
///
|
||||
/// # Errors
|
||||
/// [`RemoteError`] on failure.
|
||||
async fn connect(&self) -> Result<(), RemoteError>;
|
||||
|
||||
/// Returns the filesystem port for this host.
|
||||
fn file_system(&self) -> Arc<dyn FileSystem>;
|
||||
|
||||
/// Returns the process spawner for this host.
|
||||
fn process_spawner(&self) -> Arc<dyn ProcessSpawner>;
|
||||
|
||||
/// Returns the PTY port for this host.
|
||||
fn pty(&self) -> Arc<dyn PtyPort>;
|
||||
}
|
||||
|
||||
/// CRUD + versioning for agent templates in the global IDE store.
|
||||
#[async_trait]
|
||||
pub trait TemplateStore: Send + Sync {
|
||||
/// Lists all templates.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`StoreError`] on failure.
|
||||
async fn list(&self) -> Result<Vec<AgentTemplate>, StoreError>;
|
||||
|
||||
/// Gets a template by id.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`StoreError::NotFound`] if absent.
|
||||
async fn get(&self, id: crate::ids::TemplateId) -> Result<AgentTemplate, StoreError>;
|
||||
|
||||
/// Saves (creates or replaces) a template.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`StoreError`] on failure.
|
||||
async fn save(&self, template: &AgentTemplate) -> Result<(), StoreError>;
|
||||
|
||||
/// Deletes a template.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`StoreError`] on failure.
|
||||
async fn delete(&self, id: crate::ids::TemplateId) -> Result<(), StoreError>;
|
||||
}
|
||||
|
||||
/// Persistence of the known-projects registry and the workspace.
|
||||
#[async_trait]
|
||||
pub trait ProjectStore: Send + Sync {
|
||||
/// Lists all known projects.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`StoreError`] on failure.
|
||||
async fn list_projects(&self) -> Result<Vec<Project>, StoreError>;
|
||||
|
||||
/// Loads a single project.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`StoreError::NotFound`] if absent.
|
||||
async fn load_project(&self, id: crate::ids::ProjectId) -> Result<Project, StoreError>;
|
||||
|
||||
/// Saves (creates or replaces) a project.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`StoreError`] on failure.
|
||||
async fn save_project(&self, project: &Project) -> Result<(), StoreError>;
|
||||
|
||||
/// Saves the whole workspace (windows/tabs/layouts).
|
||||
///
|
||||
/// # Errors
|
||||
/// [`StoreError`] on failure.
|
||||
async fn save_workspace(&self, workspace: &crate::layout::Workspace) -> Result<(), StoreError>;
|
||||
|
||||
/// Loads the persisted workspace.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`StoreError`] on failure.
|
||||
async fn load_workspace(&self) -> Result<crate::layout::Workspace, StoreError>;
|
||||
}
|
||||
|
||||
/// CRUD for the configured [`AgentProfile`]s in the global IDE store
|
||||
/// (`profiles.json`, ARCHITECTURE §9.2). Profiles are the *data* that drives the
|
||||
/// single generic [`AgentRuntime`] adapter (Open/Closed).
|
||||
#[async_trait]
|
||||
pub trait ProfileStore: Send + Sync {
|
||||
/// Lists all configured profiles.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`StoreError`] on failure.
|
||||
async fn list(&self) -> Result<Vec<AgentProfile>, StoreError>;
|
||||
|
||||
/// Saves (creates or replaces by id) a profile.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`StoreError`] on failure.
|
||||
async fn save(&self, profile: &AgentProfile) -> Result<(), StoreError>;
|
||||
|
||||
/// Deletes a profile by id.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`StoreError`] on failure.
|
||||
async fn delete(&self, id: crate::ids::ProfileId) -> Result<(), StoreError>;
|
||||
|
||||
/// Whether the profiles store has been initialised yet — used to detect the
|
||||
/// first run of the IDE (no `profiles.json` ⇒ first run).
|
||||
///
|
||||
/// # Errors
|
||||
/// [`StoreError`] on failure.
|
||||
async fn is_configured(&self) -> Result<bool, StoreError>;
|
||||
|
||||
/// Persists an (possibly empty) profiles store, recording that the first run
|
||||
/// is complete even when the user kept no profile.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`StoreError`] on failure.
|
||||
async fn mark_configured(&self) -> Result<(), StoreError>;
|
||||
}
|
||||
|
||||
/// Reads/writes agent `.md` contexts and the project manifest, within a project.
|
||||
#[async_trait]
|
||||
pub trait AgentContextStore: Send + Sync {
|
||||
/// Reads an agent's context.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`StoreError`] on failure.
|
||||
async fn read_context(
|
||||
&self,
|
||||
project: &Project,
|
||||
agent: &AgentId,
|
||||
) -> Result<MarkdownDoc, StoreError>;
|
||||
|
||||
/// Writes an agent's context.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`StoreError`] on failure.
|
||||
async fn write_context(
|
||||
&self,
|
||||
project: &Project,
|
||||
agent: &AgentId,
|
||||
md: &MarkdownDoc,
|
||||
) -> Result<(), StoreError>;
|
||||
|
||||
/// Loads the project manifest.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`StoreError`] on failure.
|
||||
async fn load_manifest(&self, project: &Project) -> Result<AgentManifest, StoreError>;
|
||||
|
||||
/// Saves the project manifest.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`StoreError`] on failure.
|
||||
async fn save_manifest(
|
||||
&self,
|
||||
project: &Project,
|
||||
manifest: &AgentManifest,
|
||||
) -> Result<(), StoreError>;
|
||||
}
|
||||
|
||||
/// Git operations for a project. Named `GitPort` to avoid clashing with the
|
||||
/// [`crate::git::GitRepository`] *entity* (state image).
|
||||
#[async_trait]
|
||||
pub trait GitPort: Send + Sync {
|
||||
/// Initialises a repository at the root.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`GitError`] on failure.
|
||||
async fn init(&self, root: &ProjectPath) -> Result<(), GitError>;
|
||||
|
||||
/// Returns the status of changed paths.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`GitError`] on failure.
|
||||
async fn status(&self, root: &ProjectPath) -> Result<Vec<GitFileStatus>, GitError>;
|
||||
|
||||
/// Stages a path.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`GitError`] on failure.
|
||||
async fn stage(&self, root: &ProjectPath, path: &str) -> Result<(), GitError>;
|
||||
|
||||
/// Unstages a path.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`GitError`] on failure.
|
||||
async fn unstage(&self, root: &ProjectPath, path: &str) -> Result<(), GitError>;
|
||||
|
||||
/// Creates a commit with the given message.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`GitError`] on failure.
|
||||
async fn commit(&self, root: &ProjectPath, message: &str) -> Result<GitCommitInfo, GitError>;
|
||||
|
||||
/// Lists branches.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`GitError`] on failure.
|
||||
async fn branches(&self, root: &ProjectPath) -> Result<Vec<String>, GitError>;
|
||||
|
||||
/// Returns the current branch.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`GitError`] on failure.
|
||||
async fn current_branch(&self, root: &ProjectPath) -> Result<Option<String>, GitError>;
|
||||
|
||||
/// Checks out a branch.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`GitError`] on failure.
|
||||
async fn checkout(&self, root: &ProjectPath, branch: &str) -> Result<(), GitError>;
|
||||
|
||||
/// Returns the recent commit log.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`GitError`] on failure.
|
||||
async fn log(&self, root: &ProjectPath, limit: usize)
|
||||
-> Result<Vec<GitCommitInfo>, GitError>;
|
||||
|
||||
/// Returns the commit graph (all local branches, topological + time sort).
|
||||
///
|
||||
/// # Errors
|
||||
/// [`GitError`] on failure.
|
||||
async fn log_graph(
|
||||
&self,
|
||||
root: &ProjectPath,
|
||||
limit: usize,
|
||||
) -> Result<Vec<GraphCommit>, GitError>;
|
||||
|
||||
/// Pulls from the default remote.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`GitError`] on failure.
|
||||
async fn pull(&self, root: &ProjectPath) -> Result<(), GitError>;
|
||||
|
||||
/// Pushes to the default remote.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`GitError`] on failure.
|
||||
async fn push(&self, root: &ProjectPath) -> Result<(), GitError>;
|
||||
}
|
||||
|
||||
/// Publish/subscribe domain events. Synchronous, in-process.
|
||||
pub trait EventBus: Send + Sync {
|
||||
/// Publishes an event.
|
||||
fn publish(&self, event: DomainEvent);
|
||||
|
||||
/// Subscribes to the event stream.
|
||||
fn subscribe(&self) -> EventStream;
|
||||
}
|
||||
|
||||
/// Provides the current time (epoch milliseconds), abstracted for determinism.
|
||||
pub trait Clock: Send + Sync {
|
||||
/// Returns "now" as epoch milliseconds.
|
||||
fn now_millis(&self) -> i64;
|
||||
}
|
||||
|
||||
/// Generates fresh UUIDs, abstracted for determinism in tests.
|
||||
pub trait IdGenerator: Send + Sync {
|
||||
/// Returns a fresh UUID.
|
||||
fn new_uuid(&self) -> uuid::Uuid;
|
||||
}
|
||||
133
crates/domain/src/profile.rs
Normal file
133
crates/domain/src/profile.rs
Normal file
@ -0,0 +1,133 @@
|
||||
//! AI runtime profile and its context-injection strategy.
|
||||
//!
|
||||
//! A profile is *declarative configuration* (see CONTEXT.md §9): adding an AI
|
||||
//! means adding data, not code (Open/Closed). The [`crate::ports::AgentRuntime`]
|
||||
//! port is parameterised by an [`AgentProfile`].
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::error::DomainError;
|
||||
use crate::ids::ProfileId;
|
||||
|
||||
/// Strategy for injecting an agent's `.md` context into the launched CLI.
|
||||
///
|
||||
/// Invariants:
|
||||
/// - `ConventionFile.target` is a relative file name without `..` and not absolute,
|
||||
/// - `Env.var` is a valid environment-variable identifier,
|
||||
/// - `Flag.flag` is non-empty.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", tag = "strategy")]
|
||||
pub enum ContextInjection {
|
||||
/// Write/symlink the `.md` to a conventional file (e.g. `CLAUDE.md`).
|
||||
ConventionFile {
|
||||
/// Relative target file name.
|
||||
target: String,
|
||||
},
|
||||
/// Pass the context file path through a CLI flag.
|
||||
Flag {
|
||||
/// The flag template (e.g. `--context-file {path}` or `-f`).
|
||||
flag: String,
|
||||
},
|
||||
/// Pipe the Markdown content on stdin.
|
||||
Stdin,
|
||||
/// Pass the context via an environment variable.
|
||||
Env {
|
||||
/// Environment variable name.
|
||||
var: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl ContextInjection {
|
||||
/// Validated `ConventionFile` constructor.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`DomainError::PathNotRelativeSafe`] if `target` is absolute or
|
||||
/// contains `..`.
|
||||
pub fn convention_file(target: impl Into<String>) -> Result<Self, DomainError> {
|
||||
let target = target.into();
|
||||
crate::validation::relative_safe(&target)?;
|
||||
Ok(Self::ConventionFile { target })
|
||||
}
|
||||
|
||||
/// Validated `Flag` constructor.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`DomainError::EmptyField`] if `flag` is empty.
|
||||
pub fn flag(flag: impl Into<String>) -> Result<Self, DomainError> {
|
||||
let flag = flag.into();
|
||||
crate::validation::non_empty(&flag, "contextInjection.flag")?;
|
||||
Ok(Self::Flag { flag })
|
||||
}
|
||||
|
||||
/// `Stdin` constructor (no validation needed).
|
||||
#[must_use]
|
||||
pub const fn stdin() -> Self {
|
||||
Self::Stdin
|
||||
}
|
||||
|
||||
/// Validated `Env` constructor.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`DomainError::InvalidEnvVar`] if `var` is not a valid identifier.
|
||||
pub fn env(var: impl Into<String>) -> Result<Self, DomainError> {
|
||||
let var = var.into();
|
||||
crate::validation::valid_env_var(&var)?;
|
||||
Ok(Self::Env { var })
|
||||
}
|
||||
}
|
||||
|
||||
/// Declarative runtime configuration for one AI CLI.
|
||||
///
|
||||
/// Invariants:
|
||||
/// - `name` and `command` non-empty,
|
||||
/// - `context_injection` is itself valid (guaranteed by its constructors).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AgentProfile {
|
||||
/// Stable identifier.
|
||||
pub id: ProfileId,
|
||||
/// Display name.
|
||||
pub name: String,
|
||||
/// Launch command (e.g. `claude`, `codex`, `gemini`, `aider`).
|
||||
pub command: String,
|
||||
/// Static arguments.
|
||||
pub args: Vec<String>,
|
||||
/// Context-injection strategy.
|
||||
pub context_injection: ContextInjection,
|
||||
/// Optional detection command (e.g. `claude --version`).
|
||||
pub detect: Option<String>,
|
||||
/// Working-directory template (e.g. `"{projectRoot}"`).
|
||||
pub cwd_template: String,
|
||||
}
|
||||
|
||||
impl AgentProfile {
|
||||
/// Builds a validated profile.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`DomainError::EmptyField`] if `name` or `command` is empty.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
id: ProfileId,
|
||||
name: impl Into<String>,
|
||||
command: impl Into<String>,
|
||||
args: Vec<String>,
|
||||
context_injection: ContextInjection,
|
||||
detect: Option<String>,
|
||||
cwd_template: impl Into<String>,
|
||||
) -> Result<Self, DomainError> {
|
||||
let name = name.into();
|
||||
let command = command.into();
|
||||
let cwd_template = cwd_template.into();
|
||||
crate::validation::non_empty(&name, "profile.name")?;
|
||||
crate::validation::non_empty(&command, "profile.command")?;
|
||||
Ok(Self {
|
||||
id,
|
||||
name,
|
||||
command,
|
||||
args,
|
||||
context_injection,
|
||||
detect,
|
||||
cwd_template,
|
||||
})
|
||||
}
|
||||
}
|
||||
115
crates/domain/src/project.rs
Normal file
115
crates/domain/src/project.rs
Normal file
@ -0,0 +1,115 @@
|
||||
//! Project aggregate root and its path value object.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::error::DomainError;
|
||||
use crate::ids::ProjectId;
|
||||
use crate::remote::RemoteRef;
|
||||
|
||||
/// A normalized, absolute project path, aware of its target platform.
|
||||
///
|
||||
/// Invariant: the path is **absolute** for its platform. We accept POSIX
|
||||
/// absolute paths (`/...`), Windows absolute paths (`C:\...` or `\\server\...`)
|
||||
/// and WSL mount paths (`/mnt/c/...`, themselves POSIX-absolute).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct ProjectPath(String);
|
||||
|
||||
impl ProjectPath {
|
||||
/// Builds a validated project path.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`DomainError::PathNotAbsolute`] if `raw` is not absolute, or
|
||||
/// [`DomainError::EmptyField`] if it is empty.
|
||||
pub fn new(raw: impl Into<String>) -> Result<Self, DomainError> {
|
||||
let raw = raw.into();
|
||||
crate::validation::non_empty(&raw, "project.root")?;
|
||||
if Self::is_absolute(&raw) {
|
||||
Ok(Self(raw))
|
||||
} else {
|
||||
Err(DomainError::PathNotAbsolute { path: raw })
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns whether `raw` is an absolute path on any supported platform.
|
||||
fn is_absolute(raw: &str) -> bool {
|
||||
let bytes = raw.as_bytes();
|
||||
// POSIX / WSL absolute.
|
||||
if bytes.first() == Some(&b'/') {
|
||||
return true;
|
||||
}
|
||||
// Windows UNC.
|
||||
if raw.starts_with("\\\\") {
|
||||
return true;
|
||||
}
|
||||
// Windows drive-letter absolute: `C:\` or `C:/`.
|
||||
if bytes.len() >= 3
|
||||
&& bytes[0].is_ascii_alphabetic()
|
||||
&& bytes[1] == b':'
|
||||
&& (bytes[2] == b'\\' || bytes[2] == b'/')
|
||||
{
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Returns the raw path string.
|
||||
#[must_use]
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ProjectPath {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(&self.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Project aggregate root.
|
||||
///
|
||||
/// Invariants enforced here:
|
||||
/// - `name` non-empty,
|
||||
/// - `root` is an absolute [`ProjectPath`].
|
||||
///
|
||||
/// The cross-aggregate uniqueness invariant — "no two projects share the same
|
||||
/// `(remote, root)`" — is a *repository* concern (it requires knowledge of all
|
||||
/// projects) and is enforced by the `ProjectStore`/use case layer, not here.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Project {
|
||||
/// Stable identifier.
|
||||
pub id: ProjectId,
|
||||
/// Display name.
|
||||
pub name: String,
|
||||
/// Absolute project root.
|
||||
pub root: ProjectPath,
|
||||
/// Where the project physically lives.
|
||||
pub remote: RemoteRef,
|
||||
/// Creation timestamp, as epoch milliseconds (supplied via a `Clock` port).
|
||||
pub created_at: i64,
|
||||
}
|
||||
|
||||
impl Project {
|
||||
/// Builds a validated project.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`DomainError::EmptyField`] if `name` is empty.
|
||||
pub fn new(
|
||||
id: ProjectId,
|
||||
name: impl Into<String>,
|
||||
root: ProjectPath,
|
||||
remote: RemoteRef,
|
||||
created_at: i64,
|
||||
) -> Result<Self, DomainError> {
|
||||
let name = name.into();
|
||||
crate::validation::non_empty(&name, "project.name")?;
|
||||
Ok(Self {
|
||||
id,
|
||||
name,
|
||||
root,
|
||||
remote,
|
||||
created_at,
|
||||
})
|
||||
}
|
||||
}
|
||||
128
crates/domain/src/remote.rs
Normal file
128
crates/domain/src/remote.rs
Normal file
@ -0,0 +1,128 @@
|
||||
//! Remote-location value objects: the strategy that locates where a project's
|
||||
//! filesystem and processes actually live (local, SSH, or WSL).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::error::DomainError;
|
||||
|
||||
/// SSH authentication strategy.
|
||||
///
|
||||
/// The domain only models *which* strategy is selected; resolving keys,
|
||||
/// agents or known-hosts is an infrastructure concern.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", tag = "type")]
|
||||
pub enum SshAuth {
|
||||
/// Use the running SSH agent.
|
||||
Agent,
|
||||
/// Use a private-key file at the given (remote-machine-agnostic) path.
|
||||
Key {
|
||||
/// Path to the private key on the local machine.
|
||||
path: String,
|
||||
},
|
||||
/// Interactive / stored password authentication.
|
||||
Password,
|
||||
}
|
||||
|
||||
/// Strategy describing where a project is physically located and executed.
|
||||
///
|
||||
/// Invariants:
|
||||
/// - `Ssh.port` ∈ `1..=65535`,
|
||||
/// - `Ssh.host` / `Ssh.user` / `Ssh.remote_root` non-empty,
|
||||
/// - `Wsl.distro` non-empty.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", tag = "kind")]
|
||||
pub enum RemoteRef {
|
||||
/// The local machine.
|
||||
Local,
|
||||
/// A remote host reached over SSH.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
Ssh {
|
||||
/// Hostname or IP.
|
||||
host: String,
|
||||
/// TCP port (`1..=65535`).
|
||||
port: u16,
|
||||
/// Remote user.
|
||||
user: String,
|
||||
/// Authentication strategy.
|
||||
auth: SshAuth,
|
||||
/// Absolute root path on the remote machine.
|
||||
remote_root: String,
|
||||
},
|
||||
/// A Windows Subsystem for Linux distribution.
|
||||
Wsl {
|
||||
/// Distribution name (e.g. `Ubuntu-22.04`).
|
||||
distro: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl RemoteRef {
|
||||
/// Convenience constructor for the local machine.
|
||||
#[must_use]
|
||||
pub const fn local() -> Self {
|
||||
Self::Local
|
||||
}
|
||||
|
||||
/// Validated constructor for an SSH remote.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`DomainError`] if the port is `0`, or if `host`, `user` or
|
||||
/// `remote_root` is empty.
|
||||
pub fn ssh(
|
||||
host: impl Into<String>,
|
||||
port: u16,
|
||||
user: impl Into<String>,
|
||||
auth: SshAuth,
|
||||
remote_root: impl Into<String>,
|
||||
) -> Result<Self, DomainError> {
|
||||
let host = host.into();
|
||||
let user = user.into();
|
||||
let remote_root = remote_root.into();
|
||||
crate::validation::non_empty(&host, "ssh.host")?;
|
||||
crate::validation::non_empty(&user, "ssh.user")?;
|
||||
crate::validation::non_empty(&remote_root, "ssh.remote_root")?;
|
||||
// u16 already bounds the upper end; only port 0 is invalid.
|
||||
if port == 0 {
|
||||
return Err(DomainError::InvalidPort { port: 0 });
|
||||
}
|
||||
Ok(Self::Ssh {
|
||||
host,
|
||||
port,
|
||||
user,
|
||||
auth,
|
||||
remote_root,
|
||||
})
|
||||
}
|
||||
|
||||
/// Validated constructor for a WSL remote.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`DomainError`] if `distro` is empty.
|
||||
pub fn wsl(distro: impl Into<String>) -> Result<Self, DomainError> {
|
||||
let distro = distro.into();
|
||||
crate::validation::non_empty(&distro, "wsl.distro")?;
|
||||
Ok(Self::Wsl { distro })
|
||||
}
|
||||
|
||||
/// Returns the coarse kind of this remote, for adapter selection.
|
||||
#[must_use]
|
||||
pub fn kind(&self) -> RemoteKind {
|
||||
match self {
|
||||
Self::Local => RemoteKind::Local,
|
||||
Self::Ssh { .. } => RemoteKind::Ssh,
|
||||
Self::Wsl { .. } => RemoteKind::Wsl,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Coarse discriminant used by the [`crate::ports::RemoteHost`] port to advertise
|
||||
/// its kind without exposing connection details.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum RemoteKind {
|
||||
/// Local execution.
|
||||
Local,
|
||||
/// SSH remote.
|
||||
Ssh,
|
||||
/// WSL distribution.
|
||||
Wsl,
|
||||
}
|
||||
92
crates/domain/src/template.rs
Normal file
92
crates/domain/src/template.rs
Normal file
@ -0,0 +1,92 @@
|
||||
//! Agent templates (global IDE store) and their monotonic versioning.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::error::DomainError;
|
||||
use crate::ids::{ProfileId, TemplateId};
|
||||
use crate::markdown::MarkdownDoc;
|
||||
|
||||
/// Monotonically increasing template version.
|
||||
///
|
||||
/// Invariant (enforced via [`TemplateVersion::next`]): a version only ever
|
||||
/// increases by one when the template content changes (ARCHITECTURE §8.1).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct TemplateVersion(pub u64);
|
||||
|
||||
impl TemplateVersion {
|
||||
/// The initial version assigned to a freshly created template.
|
||||
pub const INITIAL: Self = Self(1);
|
||||
|
||||
/// Returns the next version (current + 1).
|
||||
#[must_use]
|
||||
pub const fn next(self) -> Self {
|
||||
Self(self.0 + 1)
|
||||
}
|
||||
|
||||
/// Returns the raw version number.
|
||||
#[must_use]
|
||||
pub const fn get(self) -> u64 {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for TemplateVersion {
|
||||
fn default() -> Self {
|
||||
Self::INITIAL
|
||||
}
|
||||
}
|
||||
|
||||
/// A reusable agent template stored in the global IDE store.
|
||||
///
|
||||
/// Invariants:
|
||||
/// - `name` non-empty,
|
||||
/// - `version` is monotonic; bumping is done via [`AgentTemplate::with_updated_content`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AgentTemplate {
|
||||
/// Stable identifier.
|
||||
pub id: TemplateId,
|
||||
/// Display name.
|
||||
pub name: String,
|
||||
/// Markdown content.
|
||||
pub content_md: MarkdownDoc,
|
||||
/// Current version.
|
||||
pub version: TemplateVersion,
|
||||
/// Default runtime profile for agents created from this template.
|
||||
pub default_profile_id: ProfileId,
|
||||
}
|
||||
|
||||
impl AgentTemplate {
|
||||
/// Builds a validated template at [`TemplateVersion::INITIAL`].
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`DomainError::EmptyField`] if `name` is empty.
|
||||
pub fn new(
|
||||
id: TemplateId,
|
||||
name: impl Into<String>,
|
||||
content_md: MarkdownDoc,
|
||||
default_profile_id: ProfileId,
|
||||
) -> Result<Self, DomainError> {
|
||||
let name = name.into();
|
||||
crate::validation::non_empty(&name, "template.name")?;
|
||||
Ok(Self {
|
||||
id,
|
||||
name,
|
||||
content_md,
|
||||
version: TemplateVersion::INITIAL,
|
||||
default_profile_id,
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns a copy of this template with new content and the version bumped
|
||||
/// by one, preserving the monotonic-version invariant.
|
||||
#[must_use]
|
||||
pub fn with_updated_content(&self, content_md: MarkdownDoc) -> Self {
|
||||
Self {
|
||||
content_md,
|
||||
version: self.version.next(),
|
||||
..self.clone()
|
||||
}
|
||||
}
|
||||
}
|
||||
105
crates/domain/src/terminal.rs
Normal file
105
crates/domain/src/terminal.rs
Normal file
@ -0,0 +1,105 @@
|
||||
//! Terminal session entity and supporting value objects.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::error::DomainError;
|
||||
use crate::ids::{AgentId, NodeId, SessionId};
|
||||
use crate::project::ProjectPath;
|
||||
|
||||
/// Dimensions of a pseudo-terminal, in character cells.
|
||||
///
|
||||
/// Invariant: `rows > 0 && cols > 0`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PtySize {
|
||||
/// Number of rows.
|
||||
pub rows: u16,
|
||||
/// Number of columns.
|
||||
pub cols: u16,
|
||||
}
|
||||
|
||||
impl PtySize {
|
||||
/// Builds a validated PTY size.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`DomainError::InvalidPtySize`] if either dimension is zero.
|
||||
pub fn new(rows: u16, cols: u16) -> Result<Self, DomainError> {
|
||||
if rows == 0 || cols == 0 {
|
||||
return Err(DomainError::InvalidPtySize { rows, cols });
|
||||
}
|
||||
Ok(Self { rows, cols })
|
||||
}
|
||||
}
|
||||
|
||||
/// What a terminal session is running.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", tag = "type")]
|
||||
pub enum SessionKind {
|
||||
/// A plain shell terminal.
|
||||
Plain,
|
||||
/// A terminal launched for a specific agent.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
Agent {
|
||||
/// The agent driving this session.
|
||||
agent_id: AgentId,
|
||||
},
|
||||
}
|
||||
|
||||
/// Lifecycle status of a terminal session.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", tag = "state")]
|
||||
pub enum SessionStatus {
|
||||
/// Spawn requested, not yet confirmed running.
|
||||
Starting,
|
||||
/// Running.
|
||||
Running,
|
||||
/// Exited with a code.
|
||||
Exited {
|
||||
/// Process exit code.
|
||||
code: i32,
|
||||
},
|
||||
}
|
||||
|
||||
/// A terminal session hosted by a layout leaf cell.
|
||||
///
|
||||
/// Invariants:
|
||||
/// - `pty_size` is valid (`rows>0 && cols>0`),
|
||||
/// - "at most one active session per leaf" is a *layout* invariant enforced in
|
||||
/// [`crate::layout`], not here (a session does not own the leaf).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TerminalSession {
|
||||
/// Stable identifier.
|
||||
pub id: SessionId,
|
||||
/// Layout leaf hosting this session.
|
||||
pub node_id: NodeId,
|
||||
/// Working directory.
|
||||
pub cwd: ProjectPath,
|
||||
/// What the session runs.
|
||||
pub kind: SessionKind,
|
||||
/// Current terminal size.
|
||||
pub pty_size: PtySize,
|
||||
/// Lifecycle status.
|
||||
pub status: SessionStatus,
|
||||
}
|
||||
|
||||
impl TerminalSession {
|
||||
/// Builds a terminal session (in `Starting` state).
|
||||
#[must_use]
|
||||
pub fn starting(
|
||||
id: SessionId,
|
||||
node_id: NodeId,
|
||||
cwd: ProjectPath,
|
||||
kind: SessionKind,
|
||||
pty_size: PtySize,
|
||||
) -> Self {
|
||||
Self {
|
||||
id,
|
||||
node_id,
|
||||
cwd,
|
||||
kind,
|
||||
pty_size,
|
||||
status: SessionStatus::Starting,
|
||||
}
|
||||
}
|
||||
}
|
||||
59
crates/domain/src/validation.rs
Normal file
59
crates/domain/src/validation.rs
Normal file
@ -0,0 +1,59 @@
|
||||
//! Small pure validation helpers shared across value objects.
|
||||
|
||||
use crate::error::DomainError;
|
||||
|
||||
/// Returns the trimmed string if non-empty, otherwise [`DomainError::EmptyField`].
|
||||
pub(crate) fn non_empty(value: &str, field: &'static str) -> Result<(), DomainError> {
|
||||
if value.trim().is_empty() {
|
||||
Err(DomainError::EmptyField { field })
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Validates that `var` is a syntactically valid environment-variable identifier:
|
||||
/// non-empty, starts with a letter or `_`, and contains only ASCII alphanumeric
|
||||
/// characters or `_`.
|
||||
pub(crate) fn valid_env_var(var: &str) -> Result<(), DomainError> {
|
||||
let invalid = || DomainError::InvalidEnvVar {
|
||||
value: var.to_string(),
|
||||
};
|
||||
let mut chars = var.chars();
|
||||
match chars.next() {
|
||||
Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
|
||||
_ => return Err(invalid()),
|
||||
}
|
||||
if chars.all(|c| c.is_ascii_alphanumeric() || c == '_') {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(invalid())
|
||||
}
|
||||
}
|
||||
|
||||
/// Validates that a path is relative and does not escape its root via `..`.
|
||||
///
|
||||
/// Used for [`crate::profile::ContextInjection::ConventionFile`] targets and
|
||||
/// manifest `.md` paths, which must stay inside the project / `.ideai/` tree.
|
||||
pub(crate) fn relative_safe(path: &str) -> Result<(), DomainError> {
|
||||
let err = || DomainError::PathNotRelativeSafe {
|
||||
path: path.to_string(),
|
||||
};
|
||||
if path.is_empty() {
|
||||
return Err(err());
|
||||
}
|
||||
// Reject absolute POSIX paths and Windows drive / UNC paths.
|
||||
let bytes = path.as_bytes();
|
||||
if bytes[0] == b'/' || bytes[0] == b'\\' {
|
||||
return Err(err());
|
||||
}
|
||||
if path.len() >= 2 && bytes[1] == b':' && bytes[0].is_ascii_alphabetic() {
|
||||
return Err(err());
|
||||
}
|
||||
// Reject any `..` traversal component (handle both separators).
|
||||
for component in path.split(['/', '\\']) {
|
||||
if component == ".." {
|
||||
return Err(err());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user