use crate::domain::Task;
use crate::tmux::{self, Tmux};
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("tmux: {0}")]
Tmux(#[from] tmux::Error),
#[error("task {task}: no live session named {session}")]
NoSession { task: i64, session: String },
#[error("session {session} is not working in task {task}'s workspace")]
NotOurs { task: i64, session: String },
#[error("text with control characters cannot be sent to an agent")]
ControlCharacter,
}
pub type Result<T> = std::result::Result<T, Error>;
pub fn owns_session(tmux: &Tmux, task: &Task, session: &str) -> bool {
let Ok(cwd) = tmux.session_cwd(session) else {
return false;
};
let (Ok(cwd), Ok(workspace)) = (
std::fs::canonicalize(&cwd),
std::fs::canonicalize(&task.workspace_dir),
) else {
return false;
};
cwd.starts_with(workspace)
}
pub fn pane(tmux: &Tmux, task: &Task) -> Result<String> {
let Some(session) = task.session_name.clone() else {
return Err(Error::NoSession {
task: task.id,
session: "none recorded".to_string(),
});
};
if !tmux.session_exists(&session)? {
return Err(Error::NoSession {
task: task.id,
session,
});
}
if !owns_session(tmux, task, &session) {
return Err(Error::NotOurs {
task: task.id,
session,
});
}
tmux.list_panes(&session)?
.into_iter()
.next()
.ok_or(Error::NoSession {
task: task.id,
session,
})
}
pub fn say(tmux: &Tmux, task: &Task, text: &str) -> Result<()> {
if text.chars().any(char::is_control) {
return Err(Error::ControlCharacter);
}
let pane = pane(tmux, task)?;
tmux.send_keys(&pane, text)?;
tmux.send_key(&pane, "Enter")?;
Ok(())
}
pub fn press(tmux: &Tmux, task: &Task, key: &str) -> Result<()> {
let pane = pane(tmux, task)?;
tmux.send_key(&pane, key)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::store::Store;
use crate::tmux::testing::TestServer;
use chrono::Utc;
use std::path::Path;
use tempfile::TempDir;
fn task_in(dir: &Path, store: &mut Store) -> Task {
let workspace_root = dir.join("tasks");
let task = store
.create_task("a task", "do it", &workspace_root, &[], Utc::now())
.unwrap();
std::fs::create_dir_all(&task.workspace_dir).unwrap();
store
.set_session_name(task.id, &tmux::session_name(None, task.id), Utc::now())
.unwrap();
store.get_task(task.id).unwrap()
}
#[test]
fn a_session_sitting_somewhere_else_is_not_this_tasks() {
let dir = TempDir::new().unwrap();
let server = TestServer::new();
let mut store = Store::open_in_memory().unwrap();
let task = task_in(dir.path(), &mut store);
let elsewhere = dir.path().join("not-the-workspace");
std::fs::create_dir_all(&elsewhere).unwrap();
server
.tmux
.new_session(&tmux::session_name(None, task.id), &elsewhere, (80, 24))
.unwrap();
assert!(!owns_session(
&server.tmux,
&task,
&tmux::session_name(None, task.id)
));
assert!(
matches!(
say(&server.tmux, &task, "hello"),
Err(Error::NotOurs { .. })
),
"a namesake session must be refused, not typed into"
);
}
#[test]
fn a_session_in_the_tasks_own_workspace_is_reachable() {
let dir = TempDir::new().unwrap();
let server = TestServer::new();
let mut store = Store::open_in_memory().unwrap();
let task = task_in(dir.path(), &mut store);
server
.tmux
.new_session(
&tmux::session_name(None, task.id),
&task.workspace_dir,
(80, 24),
)
.unwrap();
assert!(owns_session(
&server.tmux,
&task,
&tmux::session_name(None, task.id)
));
say(&server.tmux, &task, "hello").expect("its own session");
}
#[test]
fn control_characters_never_reach_an_agent() {
let dir = TempDir::new().unwrap();
let server = TestServer::new();
let mut store = Store::open_in_memory().unwrap();
let task = task_in(dir.path(), &mut store);
server
.tmux
.new_session(
&tmux::session_name(None, task.id),
&task.workspace_dir,
(80, 24),
)
.unwrap();
for text in ["stop\rnow", "oops\x03", "two\nlines"] {
assert!(
matches!(say(&server.tmux, &task, text), Err(Error::ControlCharacter)),
"{text:?} should be refused"
);
}
}
#[test]
fn a_task_with_no_session_at_all_is_an_error_not_a_silent_success() {
let dir = TempDir::new().unwrap();
let server = TestServer::new();
let mut store = Store::open_in_memory().unwrap();
let task = task_in(dir.path(), &mut store);
assert!(matches!(
say(&server.tmux, &task, "hello"),
Err(Error::NoSession { .. })
));
}
}