This commit is contained in:
@@ -7,6 +7,8 @@ rust-version.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
thiserror = "2.0"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
|
||||
@@ -2,6 +2,15 @@
|
||||
|
||||
use std::fmt;
|
||||
|
||||
mod workspace;
|
||||
|
||||
pub use workspace::{
|
||||
CommandOrigin, CommandRequestId, LayoutNode, PaneCloseDisposition, PaneDefinition, PaneId,
|
||||
PaneLaunchIntent, PanePlacement, PaneSurface, SplitAxis, SplitRatio, WorkspaceApplyOutcome,
|
||||
WorkspaceCapability, WorkspaceCommand, WorkspaceError, WorkspaceEvent, WorkspaceId,
|
||||
WorkspaceRequest, WorkspaceState,
|
||||
};
|
||||
|
||||
/// The transport Lumbridge uses to reach a user-owned remote machine.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum RemoteTransport {
|
||||
|
||||
@@ -0,0 +1,861 @@
|
||||
//! Deterministic workspace command plane shared by UI, CLI, and agents.
|
||||
|
||||
use crate::ExecutionTarget;
|
||||
use std::collections::BTreeMap;
|
||||
use std::fmt;
|
||||
use thiserror::Error;
|
||||
|
||||
const MIN_SPLIT_PERMILLE: u16 = 100;
|
||||
const MAX_SPLIT_PERMILLE: u16 = 900;
|
||||
const MAX_IDENTITY_BYTES: usize = 256;
|
||||
|
||||
macro_rules! string_id {
|
||||
($name:ident, $label:literal) => {
|
||||
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
|
||||
pub struct $name(String);
|
||||
|
||||
impl $name {
|
||||
/// Creates a stable, non-empty identifier.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
#[doc = concat!("Returns [`WorkspaceError::InvalidIdentifier`] for an empty ", $label, ".")]
|
||||
pub fn new(value: impl Into<String>) -> Result<Self, WorkspaceError> {
|
||||
let value = value.into();
|
||||
if value.trim().is_empty() || value.chars().any(char::is_control) {
|
||||
return Err(WorkspaceError::InvalidIdentifier($label));
|
||||
}
|
||||
Ok(Self(value))
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
string_id!(WorkspaceId, "workspace ID");
|
||||
string_id!(PaneId, "pane ID");
|
||||
string_id!(CommandRequestId, "command request ID");
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
|
||||
pub enum WorkspaceCapability {
|
||||
Observe,
|
||||
Configure,
|
||||
Execute,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum CommandOrigin {
|
||||
Human,
|
||||
Agent { identity: String },
|
||||
Automation { client: String },
|
||||
}
|
||||
|
||||
impl CommandOrigin {
|
||||
fn validate(&self) -> Result<(), WorkspaceError> {
|
||||
let value = match self {
|
||||
Self::Human => return Ok(()),
|
||||
Self::Agent { identity } => identity,
|
||||
Self::Automation { client } => client,
|
||||
};
|
||||
if value.trim().is_empty()
|
||||
|| value.len() > MAX_IDENTITY_BYTES
|
||||
|| value.chars().any(char::is_control)
|
||||
{
|
||||
return Err(WorkspaceError::InvalidOrigin);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum PaneSurface {
|
||||
Terminal,
|
||||
Markdown,
|
||||
Browser,
|
||||
Tools,
|
||||
Context,
|
||||
Goal,
|
||||
Review,
|
||||
}
|
||||
|
||||
/// A credential-free launch reference. Executable arguments and secret values
|
||||
/// are deliberately absent from the workspace plan.
|
||||
#[derive(Clone, Eq, PartialEq)]
|
||||
pub enum PaneLaunchIntent {
|
||||
UserShell {
|
||||
working_directory: Option<String>,
|
||||
},
|
||||
HarnessProfile {
|
||||
profile_id: String,
|
||||
working_directory: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Pane metadata suitable for persistence and automation. It intentionally has
|
||||
/// no `Debug` implementation because titles and paths may be private.
|
||||
#[derive(Clone, Eq, PartialEq)]
|
||||
pub struct PaneDefinition {
|
||||
pub id: PaneId,
|
||||
pub title: String,
|
||||
pub surface: PaneSurface,
|
||||
pub execution_target: ExecutionTarget,
|
||||
pub launch: Option<PaneLaunchIntent>,
|
||||
}
|
||||
|
||||
impl PaneDefinition {
|
||||
/// Validates a pane definition before it reaches the layout state.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`WorkspaceError::InvalidPane`] for an empty title or for a
|
||||
/// launch intent on a non-terminal pane.
|
||||
pub fn validate(&self) -> Result<(), WorkspaceError> {
|
||||
if self.title.trim().is_empty() || self.title.chars().any(char::is_control) {
|
||||
return Err(WorkspaceError::InvalidPane("pane title"));
|
||||
}
|
||||
if self.launch.is_some() && self.surface != PaneSurface::Terminal {
|
||||
return Err(WorkspaceError::InvalidPane(
|
||||
"only terminal panes may carry a launch intent",
|
||||
));
|
||||
}
|
||||
match &self.launch {
|
||||
Some(PaneLaunchIntent::HarnessProfile { profile_id, .. })
|
||||
if profile_id.trim().is_empty() || profile_id.chars().any(char::is_control) =>
|
||||
{
|
||||
Err(WorkspaceError::InvalidPane("harness profile ID"))
|
||||
}
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum SplitAxis {
|
||||
Horizontal,
|
||||
Vertical,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum PanePlacement {
|
||||
Before,
|
||||
After,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum PaneCloseDisposition {
|
||||
Detach,
|
||||
Terminate,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct SplitRatio(u16);
|
||||
|
||||
impl SplitRatio {
|
||||
/// Creates the first child's share in permille.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`WorkspaceError::InvalidSplitRatio`] outside 100..=900.
|
||||
pub fn from_permille(value: u16) -> Result<Self, WorkspaceError> {
|
||||
if !(MIN_SPLIT_PERMILLE..=MAX_SPLIT_PERMILLE).contains(&value) {
|
||||
return Err(WorkspaceError::InvalidSplitRatio(value));
|
||||
}
|
||||
Ok(Self(value))
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn permille(self) -> u16 {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for SplitRatio {
|
||||
fn default() -> Self {
|
||||
Self(500)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum LayoutNode {
|
||||
Pane(PaneId),
|
||||
Split {
|
||||
axis: SplitAxis,
|
||||
ratio: SplitRatio,
|
||||
first: Box<Self>,
|
||||
second: Box<Self>,
|
||||
},
|
||||
}
|
||||
|
||||
impl LayoutNode {
|
||||
fn contains(&self, pane: &PaneId) -> bool {
|
||||
match self {
|
||||
Self::Pane(candidate) => candidate == pane,
|
||||
Self::Split { first, second, .. } => first.contains(pane) || second.contains(pane),
|
||||
}
|
||||
}
|
||||
|
||||
fn split(
|
||||
&mut self,
|
||||
target: &PaneId,
|
||||
new_pane: PaneId,
|
||||
axis: SplitAxis,
|
||||
ratio: SplitRatio,
|
||||
placement: PanePlacement,
|
||||
) -> bool {
|
||||
match self {
|
||||
Self::Pane(candidate) if candidate == target => {
|
||||
let existing = Self::Pane(candidate.clone());
|
||||
let inserted = Self::Pane(new_pane);
|
||||
let (first, second) = match placement {
|
||||
PanePlacement::Before => (inserted, existing),
|
||||
PanePlacement::After => (existing, inserted),
|
||||
};
|
||||
*self = Self::Split {
|
||||
axis,
|
||||
ratio,
|
||||
first: Box::new(first),
|
||||
second: Box::new(second),
|
||||
};
|
||||
true
|
||||
}
|
||||
Self::Pane(_) => false,
|
||||
Self::Split { first, second, .. } => {
|
||||
first.split(target, new_pane.clone(), axis, ratio, placement)
|
||||
|| second.split(target, new_pane, axis, ratio, placement)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn remove(self, target: &PaneId) -> Option<Self> {
|
||||
match self {
|
||||
Self::Pane(pane) => (pane != *target).then_some(Self::Pane(pane)),
|
||||
Self::Split {
|
||||
axis,
|
||||
ratio,
|
||||
first,
|
||||
second,
|
||||
} => match (first.remove(target), second.remove(target)) {
|
||||
(Some(first), Some(second)) => Some(Self::Split {
|
||||
axis,
|
||||
ratio,
|
||||
first: Box::new(first),
|
||||
second: Box::new(second),
|
||||
}),
|
||||
(Some(remaining), None) | (None, Some(remaining)) => Some(remaining),
|
||||
(None, None) => None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn first_pane(&self) -> &PaneId {
|
||||
match self {
|
||||
Self::Pane(pane) => pane,
|
||||
Self::Split { first, .. } => first.first_pane(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Eq, PartialEq)]
|
||||
pub enum WorkspaceCommand {
|
||||
SplitPane {
|
||||
target: PaneId,
|
||||
pane: PaneDefinition,
|
||||
axis: SplitAxis,
|
||||
ratio: SplitRatio,
|
||||
placement: PanePlacement,
|
||||
},
|
||||
SelectPane {
|
||||
pane: PaneId,
|
||||
},
|
||||
RenamePane {
|
||||
pane: PaneId,
|
||||
title: String,
|
||||
},
|
||||
ClosePane {
|
||||
pane: PaneId,
|
||||
disposition: PaneCloseDisposition,
|
||||
},
|
||||
}
|
||||
|
||||
impl WorkspaceCommand {
|
||||
#[must_use]
|
||||
pub const fn required_capability(&self) -> WorkspaceCapability {
|
||||
match self {
|
||||
Self::ClosePane {
|
||||
disposition: PaneCloseDisposition::Terminate,
|
||||
..
|
||||
} => WorkspaceCapability::Execute,
|
||||
Self::SplitPane { pane, .. } if pane.launch.is_some() => WorkspaceCapability::Execute,
|
||||
Self::SplitPane { .. }
|
||||
| Self::SelectPane { .. }
|
||||
| Self::RenamePane { .. }
|
||||
| Self::ClosePane {
|
||||
disposition: PaneCloseDisposition::Detach,
|
||||
..
|
||||
} => WorkspaceCapability::Configure,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Eq, PartialEq)]
|
||||
pub struct WorkspaceRequest {
|
||||
pub request_id: CommandRequestId,
|
||||
pub origin: CommandOrigin,
|
||||
pub command: WorkspaceCommand,
|
||||
}
|
||||
|
||||
/// State transition emitted after an accepted command. Transcript or input
|
||||
/// content never appears in these events.
|
||||
#[derive(Clone, Eq, PartialEq)]
|
||||
pub enum WorkspaceEvent {
|
||||
PaneSplit {
|
||||
target: PaneId,
|
||||
pane: PaneId,
|
||||
},
|
||||
PaneSelected {
|
||||
pane: PaneId,
|
||||
},
|
||||
PaneRenamed {
|
||||
pane: PaneId,
|
||||
},
|
||||
PaneClosed {
|
||||
pane: PaneId,
|
||||
disposition: PaneCloseDisposition,
|
||||
},
|
||||
}
|
||||
|
||||
pub struct WorkspaceApplyOutcome {
|
||||
pub revision: u64,
|
||||
pub duplicate: bool,
|
||||
pub event: Option<WorkspaceEvent>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error, Eq, PartialEq)]
|
||||
pub enum WorkspaceError {
|
||||
#[error("invalid {0}")]
|
||||
InvalidIdentifier(&'static str),
|
||||
#[error("invalid {0}")]
|
||||
InvalidPane(&'static str),
|
||||
#[error("split ratio {0} must be between 100 and 900 permille")]
|
||||
InvalidSplitRatio(u16),
|
||||
#[error("pane does not exist: {}", .0.as_str())]
|
||||
PaneNotFound(PaneId),
|
||||
#[error("pane already exists: {}", .0.as_str())]
|
||||
PaneAlreadyExists(PaneId),
|
||||
#[error("command origin identity is invalid")]
|
||||
InvalidOrigin,
|
||||
#[error("request ID was already used for a different request: {}", .0.as_str())]
|
||||
RequestConflict(CommandRequestId),
|
||||
#[error("the last pane cannot be closed")]
|
||||
CannotCloseLastPane,
|
||||
#[error("workspace command requires {required:?} capability but only {granted:?} was granted")]
|
||||
CapabilityDenied {
|
||||
required: WorkspaceCapability,
|
||||
granted: WorkspaceCapability,
|
||||
},
|
||||
}
|
||||
|
||||
/// Deterministic layout state shared by all command-plane frontends. It has no
|
||||
/// `Debug` implementation because pane metadata may contain local paths.
|
||||
#[derive(Clone, Eq, PartialEq)]
|
||||
pub struct WorkspaceState {
|
||||
id: WorkspaceId,
|
||||
name: String,
|
||||
panes: BTreeMap<PaneId, PaneDefinition>,
|
||||
layout: LayoutNode,
|
||||
selected: PaneId,
|
||||
applied_requests: BTreeMap<CommandRequestId, WorkspaceRequest>,
|
||||
revision: u64,
|
||||
}
|
||||
|
||||
impl WorkspaceState {
|
||||
/// Creates a workspace with one root pane.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`WorkspaceError::InvalidPane`] for invalid names or pane
|
||||
/// definitions.
|
||||
pub fn new(
|
||||
id: WorkspaceId,
|
||||
name: impl Into<String>,
|
||||
root: PaneDefinition,
|
||||
) -> Result<Self, WorkspaceError> {
|
||||
let name = name.into();
|
||||
if name.trim().is_empty() || name.chars().any(char::is_control) {
|
||||
return Err(WorkspaceError::InvalidPane("workspace name"));
|
||||
}
|
||||
root.validate()?;
|
||||
let selected = root.id.clone();
|
||||
let layout = LayoutNode::Pane(root.id.clone());
|
||||
let panes = BTreeMap::from([(root.id.clone(), root)]);
|
||||
Ok(Self {
|
||||
id,
|
||||
name,
|
||||
panes,
|
||||
layout,
|
||||
selected,
|
||||
applied_requests: BTreeMap::new(),
|
||||
revision: 0,
|
||||
})
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn id(&self) -> &WorkspaceId {
|
||||
&self.id
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn name(&self) -> &str {
|
||||
&self.name
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn layout(&self) -> &LayoutNode {
|
||||
&self.layout
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn selected(&self) -> &PaneId {
|
||||
&self.selected
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn revision(&self) -> u64 {
|
||||
self.revision
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn pane(&self, id: &PaneId) -> Option<&PaneDefinition> {
|
||||
self.panes.get(id)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn pane_count(&self) -> usize {
|
||||
self.panes.len()
|
||||
}
|
||||
|
||||
/// Applies one idempotent command after checking its required capability.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Rejects insufficient authority and invalid state transitions without
|
||||
/// changing the workspace.
|
||||
pub fn apply(
|
||||
&mut self,
|
||||
request: WorkspaceRequest,
|
||||
granted: WorkspaceCapability,
|
||||
) -> Result<WorkspaceApplyOutcome, WorkspaceError> {
|
||||
if let Some(applied) = self.applied_requests.get(&request.request_id) {
|
||||
if applied == &request {
|
||||
return Ok(WorkspaceApplyOutcome {
|
||||
revision: self.revision,
|
||||
duplicate: true,
|
||||
event: None,
|
||||
});
|
||||
}
|
||||
return Err(WorkspaceError::RequestConflict(request.request_id));
|
||||
}
|
||||
request.origin.validate()?;
|
||||
let required = request.command.required_capability();
|
||||
if granted < required {
|
||||
return Err(WorkspaceError::CapabilityDenied { required, granted });
|
||||
}
|
||||
|
||||
let event = self.apply_command(request.command.clone())?;
|
||||
self.applied_requests
|
||||
.insert(request.request_id.clone(), request);
|
||||
self.revision = self.revision.saturating_add(1);
|
||||
Ok(WorkspaceApplyOutcome {
|
||||
revision: self.revision,
|
||||
duplicate: false,
|
||||
event: Some(event),
|
||||
})
|
||||
}
|
||||
|
||||
/// Applies a setup plan atomically. Any rejected command leaves the
|
||||
/// original workspace unchanged.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns the first command error without committing partial state.
|
||||
pub fn apply_plan(
|
||||
&mut self,
|
||||
requests: impl IntoIterator<Item = WorkspaceRequest>,
|
||||
granted: WorkspaceCapability,
|
||||
) -> Result<Vec<WorkspaceApplyOutcome>, WorkspaceError> {
|
||||
let mut candidate = self.clone();
|
||||
let mut outcomes = Vec::new();
|
||||
for request in requests {
|
||||
outcomes.push(candidate.apply(request, granted)?);
|
||||
}
|
||||
*self = candidate;
|
||||
Ok(outcomes)
|
||||
}
|
||||
|
||||
fn apply_command(
|
||||
&mut self,
|
||||
command: WorkspaceCommand,
|
||||
) -> Result<WorkspaceEvent, WorkspaceError> {
|
||||
match command {
|
||||
WorkspaceCommand::SplitPane {
|
||||
target,
|
||||
pane,
|
||||
axis,
|
||||
ratio,
|
||||
placement,
|
||||
} => {
|
||||
if !self.layout.contains(&target) {
|
||||
return Err(WorkspaceError::PaneNotFound(target));
|
||||
}
|
||||
if self.panes.contains_key(&pane.id) {
|
||||
return Err(WorkspaceError::PaneAlreadyExists(pane.id));
|
||||
}
|
||||
pane.validate()?;
|
||||
let pane_id = pane.id.clone();
|
||||
let did_split = self
|
||||
.layout
|
||||
.split(&target, pane_id.clone(), axis, ratio, placement);
|
||||
debug_assert!(did_split, "target presence was checked");
|
||||
self.panes.insert(pane_id.clone(), pane);
|
||||
self.selected = pane_id.clone();
|
||||
Ok(WorkspaceEvent::PaneSplit {
|
||||
target,
|
||||
pane: pane_id,
|
||||
})
|
||||
}
|
||||
WorkspaceCommand::SelectPane { pane } => {
|
||||
if !self.panes.contains_key(&pane) {
|
||||
return Err(WorkspaceError::PaneNotFound(pane));
|
||||
}
|
||||
self.selected = pane.clone();
|
||||
Ok(WorkspaceEvent::PaneSelected { pane })
|
||||
}
|
||||
WorkspaceCommand::RenamePane { pane, title } => {
|
||||
if title.trim().is_empty() || title.chars().any(char::is_control) {
|
||||
return Err(WorkspaceError::InvalidPane("pane title"));
|
||||
}
|
||||
let definition = self
|
||||
.panes
|
||||
.get_mut(&pane)
|
||||
.ok_or_else(|| WorkspaceError::PaneNotFound(pane.clone()))?;
|
||||
definition.title = title;
|
||||
Ok(WorkspaceEvent::PaneRenamed { pane })
|
||||
}
|
||||
WorkspaceCommand::ClosePane { pane, disposition } => {
|
||||
if self.panes.len() == 1 && self.panes.contains_key(&pane) {
|
||||
return Err(WorkspaceError::CannotCloseLastPane);
|
||||
}
|
||||
if !self.panes.contains_key(&pane) {
|
||||
return Err(WorkspaceError::PaneNotFound(pane));
|
||||
}
|
||||
let layout = self
|
||||
.layout
|
||||
.clone()
|
||||
.remove(&pane)
|
||||
.ok_or(WorkspaceError::CannotCloseLastPane)?;
|
||||
self.panes.remove(&pane);
|
||||
self.layout = layout;
|
||||
if self.selected == pane {
|
||||
self.selected = self.layout.first_pane().clone();
|
||||
}
|
||||
Ok(WorkspaceEvent::PaneClosed { pane, disposition })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for WorkspaceEvent {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::PaneSplit { target, pane } => formatter
|
||||
.debug_struct("PaneSplit")
|
||||
.field("target", target)
|
||||
.field("pane", pane)
|
||||
.finish(),
|
||||
Self::PaneSelected { pane } => formatter
|
||||
.debug_struct("PaneSelected")
|
||||
.field("pane", pane)
|
||||
.finish(),
|
||||
Self::PaneRenamed { pane } => formatter
|
||||
.debug_struct("PaneRenamed")
|
||||
.field("pane", pane)
|
||||
.finish(),
|
||||
Self::PaneClosed { pane, disposition } => formatter
|
||||
.debug_struct("PaneClosed")
|
||||
.field("pane", pane)
|
||||
.field("disposition", disposition)
|
||||
.finish(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
CommandOrigin, CommandRequestId, LayoutNode, PaneCloseDisposition, PaneDefinition, PaneId,
|
||||
PaneLaunchIntent, PanePlacement, PaneSurface, SplitAxis, SplitRatio, WorkspaceCapability,
|
||||
WorkspaceCommand, WorkspaceError, WorkspaceId, WorkspaceRequest, WorkspaceState,
|
||||
};
|
||||
use crate::ExecutionTarget;
|
||||
|
||||
fn id<T>(constructor: impl FnOnce(String) -> Result<T, WorkspaceError>, value: &str) -> T {
|
||||
constructor(value.to_owned()).unwrap()
|
||||
}
|
||||
|
||||
fn pane(value: &str, launch: bool) -> PaneDefinition {
|
||||
PaneDefinition {
|
||||
id: id(PaneId::new, value),
|
||||
title: value.to_owned(),
|
||||
surface: PaneSurface::Terminal,
|
||||
execution_target: ExecutionTarget::Local,
|
||||
launch: launch.then_some(PaneLaunchIntent::UserShell {
|
||||
working_directory: None,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn request(value: &str, command: WorkspaceCommand) -> WorkspaceRequest {
|
||||
WorkspaceRequest {
|
||||
request_id: id(CommandRequestId::new, value),
|
||||
origin: CommandOrigin::Agent {
|
||||
identity: "test-agent".into(),
|
||||
},
|
||||
command,
|
||||
}
|
||||
}
|
||||
|
||||
fn workspace() -> WorkspaceState {
|
||||
WorkspaceState::new(id(WorkspaceId::new, "ws"), "Workspace", pane("root", false)).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_plan_builds_a_deterministic_split_tree() {
|
||||
let mut state = workspace();
|
||||
let requests = [
|
||||
request(
|
||||
"split-1",
|
||||
WorkspaceCommand::SplitPane {
|
||||
target: id(PaneId::new, "root"),
|
||||
pane: pane("agent", false),
|
||||
axis: SplitAxis::Horizontal,
|
||||
ratio: SplitRatio::default(),
|
||||
placement: PanePlacement::After,
|
||||
},
|
||||
),
|
||||
request(
|
||||
"split-2",
|
||||
WorkspaceCommand::SplitPane {
|
||||
target: id(PaneId::new, "agent"),
|
||||
pane: pane("tests", false),
|
||||
axis: SplitAxis::Vertical,
|
||||
ratio: SplitRatio::from_permille(600).unwrap(),
|
||||
placement: PanePlacement::Before,
|
||||
},
|
||||
),
|
||||
];
|
||||
let outcomes = state
|
||||
.apply_plan(requests, WorkspaceCapability::Configure)
|
||||
.unwrap();
|
||||
assert_eq!(outcomes.len(), 2);
|
||||
assert_eq!(state.pane_count(), 3);
|
||||
assert_eq!(state.selected().as_str(), "tests");
|
||||
assert_eq!(state.revision(), 2);
|
||||
assert!(matches!(state.layout(), LayoutNode::Split { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_request_is_idempotent() {
|
||||
let mut state = workspace();
|
||||
let command = request(
|
||||
"select-root",
|
||||
WorkspaceCommand::SelectPane {
|
||||
pane: id(PaneId::new, "root"),
|
||||
},
|
||||
);
|
||||
let first = state
|
||||
.apply(command.clone(), WorkspaceCapability::Configure)
|
||||
.unwrap();
|
||||
let second = state
|
||||
.apply(command, WorkspaceCapability::Configure)
|
||||
.unwrap();
|
||||
assert!(!first.duplicate);
|
||||
assert!(second.duplicate);
|
||||
assert_eq!(state.revision(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reused_request_id_must_match_origin_and_command() {
|
||||
let mut state = workspace();
|
||||
state
|
||||
.apply(
|
||||
request(
|
||||
"shared-id",
|
||||
WorkspaceCommand::SelectPane {
|
||||
pane: id(PaneId::new, "root"),
|
||||
},
|
||||
),
|
||||
WorkspaceCapability::Configure,
|
||||
)
|
||||
.unwrap();
|
||||
let conflicting = request(
|
||||
"shared-id",
|
||||
WorkspaceCommand::RenamePane {
|
||||
pane: id(PaneId::new, "root"),
|
||||
title: "Different".into(),
|
||||
},
|
||||
);
|
||||
assert!(matches!(
|
||||
state.apply(conflicting, WorkspaceCapability::Configure),
|
||||
Err(WorkspaceError::RequestConflict(_))
|
||||
));
|
||||
assert_eq!(state.pane(&id(PaneId::new, "root")).unwrap().title, "root");
|
||||
assert_eq!(state.revision(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unattributed_agent_requests() {
|
||||
let mut state = workspace();
|
||||
let request = WorkspaceRequest {
|
||||
request_id: id(CommandRequestId::new, "origin"),
|
||||
origin: CommandOrigin::Agent {
|
||||
identity: "\n".into(),
|
||||
},
|
||||
command: WorkspaceCommand::SelectPane {
|
||||
pane: id(PaneId::new, "root"),
|
||||
},
|
||||
};
|
||||
assert!(matches!(
|
||||
state.apply(request, WorkspaceCapability::Configure),
|
||||
Err(WorkspaceError::InvalidOrigin)
|
||||
));
|
||||
assert_eq!(state.revision(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn launch_and_termination_require_execute_capability() {
|
||||
let mut state = workspace();
|
||||
let launch = request(
|
||||
"launch",
|
||||
WorkspaceCommand::SplitPane {
|
||||
target: id(PaneId::new, "root"),
|
||||
pane: pane("shell", true),
|
||||
axis: SplitAxis::Horizontal,
|
||||
ratio: SplitRatio::default(),
|
||||
placement: PanePlacement::After,
|
||||
},
|
||||
);
|
||||
assert!(matches!(
|
||||
state.apply(launch.clone(), WorkspaceCapability::Configure),
|
||||
Err(WorkspaceError::CapabilityDenied { .. })
|
||||
));
|
||||
state.apply(launch, WorkspaceCapability::Execute).unwrap();
|
||||
let terminate = request(
|
||||
"terminate",
|
||||
WorkspaceCommand::ClosePane {
|
||||
pane: id(PaneId::new, "shell"),
|
||||
disposition: PaneCloseDisposition::Terminate,
|
||||
},
|
||||
);
|
||||
assert!(matches!(
|
||||
state.apply(terminate, WorkspaceCapability::Configure),
|
||||
Err(WorkspaceError::CapabilityDenied { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_plan_is_atomic() {
|
||||
let mut state = workspace();
|
||||
let before = state.clone();
|
||||
let requests = [
|
||||
request(
|
||||
"good",
|
||||
WorkspaceCommand::SplitPane {
|
||||
target: id(PaneId::new, "root"),
|
||||
pane: pane("new", false),
|
||||
axis: SplitAxis::Horizontal,
|
||||
ratio: SplitRatio::default(),
|
||||
placement: PanePlacement::After,
|
||||
},
|
||||
),
|
||||
request(
|
||||
"bad",
|
||||
WorkspaceCommand::SelectPane {
|
||||
pane: id(PaneId::new, "missing"),
|
||||
},
|
||||
),
|
||||
];
|
||||
assert!(
|
||||
state
|
||||
.apply_plan(requests, WorkspaceCapability::Configure)
|
||||
.is_err()
|
||||
);
|
||||
assert!(state == before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_promotes_sibling_and_never_closes_last_pane() {
|
||||
let mut state = workspace();
|
||||
state
|
||||
.apply(
|
||||
request(
|
||||
"split",
|
||||
WorkspaceCommand::SplitPane {
|
||||
target: id(PaneId::new, "root"),
|
||||
pane: pane("second", false),
|
||||
axis: SplitAxis::Horizontal,
|
||||
ratio: SplitRatio::default(),
|
||||
placement: PanePlacement::After,
|
||||
},
|
||||
),
|
||||
WorkspaceCapability::Configure,
|
||||
)
|
||||
.unwrap();
|
||||
state
|
||||
.apply(
|
||||
request(
|
||||
"close",
|
||||
WorkspaceCommand::ClosePane {
|
||||
pane: id(PaneId::new, "second"),
|
||||
disposition: PaneCloseDisposition::Detach,
|
||||
},
|
||||
),
|
||||
WorkspaceCapability::Configure,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(state.selected().as_str(), "root");
|
||||
assert_eq!(state.pane_count(), 1);
|
||||
assert!(matches!(
|
||||
state.apply(
|
||||
request(
|
||||
"last",
|
||||
WorkspaceCommand::ClosePane {
|
||||
pane: id(PaneId::new, "root"),
|
||||
disposition: PaneCloseDisposition::Detach,
|
||||
},
|
||||
),
|
||||
WorkspaceCapability::Configure
|
||||
),
|
||||
Err(WorkspaceError::CannotCloseLastPane)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_identifiers_ratios_and_launch_surfaces() {
|
||||
assert!(PaneId::new(" ").is_err());
|
||||
assert!(SplitRatio::from_permille(99).is_err());
|
||||
let invalid = PaneDefinition {
|
||||
id: id(PaneId::new, "browser"),
|
||||
title: "Browser".into(),
|
||||
surface: PaneSurface::Browser,
|
||||
execution_target: ExecutionTarget::Local,
|
||||
launch: Some(PaneLaunchIntent::UserShell {
|
||||
working_directory: None,
|
||||
}),
|
||||
};
|
||||
assert!(invalid.validate().is_err());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user