feat(maestro): add maestro send for typing into a session
Adds POST /api/sessions/:id/input and a CLI command on top of it, so a script (or another agent) can hand an instruction to a running session without attaching to it. The text goes in as one write and the Enter follows 150ms later: agents that run a full-screen prompt coalesce a burst of input into a paste, and a newline inside that burst becomes a line break instead of sending the message. `--no-enter` skips the Enter and leaves the text in the prompt. The session argument takes an ID, a unique ID prefix, or a session name; names win over prefixes. A session whose pane has exited is refused (409) rather than silently accepted — unlike a stray keystroke on an attach socket, a send that goes nowhere is worth hearing about. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
766eabbcb6
commit
28778b2224
@@ -11,6 +11,7 @@ persistence, WebSocket for transport.
|
|||||||
maestro daemon tmux wrapper + session API on 127.0.0.1:7070
|
maestro daemon tmux wrapper + session API on 127.0.0.1:7070
|
||||||
├── GET/POST /api/sessions list / spawn
|
├── GET/POST /api/sessions list / spawn
|
||||||
├── DELETE /api/sessions/:id kill
|
├── DELETE /api/sessions/:id kill
|
||||||
|
├── POST /api/sessions/:id/input type text into a session
|
||||||
├── GET /api/info hostname, version, home dir, spawn presets
|
├── GET /api/info hostname, version, home dir, spawn presets
|
||||||
├── GET /api/dirs directory listing (spawn autocomplete)
|
├── GET /api/dirs directory listing (spawn autocomplete)
|
||||||
├── GET /api/recent-dirs spawn history
|
├── GET /api/recent-dirs spawn history
|
||||||
@@ -18,7 +19,7 @@ maestro daemon tmux wrapper + session API on 127.0.0.1:7070
|
|||||||
├── WS /api/events Snapshot + session deltas
|
├── WS /api/events Snapshot + session deltas
|
||||||
└── WS /api/attach/:id raw PTY bytes
|
└── WS /api/attach/:id raw PTY bytes
|
||||||
|
|
||||||
maestro list/spawn/attach/kill CLI client
|
maestro list/spawn/attach/kill/send CLI client
|
||||||
maestro tui ratatui client
|
maestro tui ratatui client
|
||||||
maestro-web serves the SPA, reverse-proxies /api/* to the daemon
|
maestro-web serves the SPA, reverse-proxies /api/* to the daemon
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -41,6 +41,9 @@ pub enum Command {
|
|||||||
Attach(AttachArgs),
|
Attach(AttachArgs),
|
||||||
/// Kill a session by ID.
|
/// Kill a session by ID.
|
||||||
Kill(KillArgs),
|
Kill(KillArgs),
|
||||||
|
/// Type text into a running session, as if it had been typed on an
|
||||||
|
/// attached terminal.
|
||||||
|
Send(SendArgs),
|
||||||
/// Install or update the agent activity hook (#106).
|
/// Install or update the agent activity hook (#106).
|
||||||
Hooks(HooksArgs),
|
Hooks(HooksArgs),
|
||||||
}
|
}
|
||||||
@@ -103,6 +106,20 @@ pub struct AttachArgs {
|
|||||||
pub detach_key: String,
|
pub detach_key: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `maestro send <session> <text...>`
|
||||||
|
#[derive(Debug, clap::Args)]
|
||||||
|
pub struct SendArgs {
|
||||||
|
/// Session to type into: its ID, a unique ID prefix, or its name.
|
||||||
|
pub session: String,
|
||||||
|
/// Text to send. Omit it to read the text from stdin.
|
||||||
|
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
|
||||||
|
pub text: Vec<String>,
|
||||||
|
/// Leave the text sitting in the session's prompt instead of pressing
|
||||||
|
/// Enter after it.
|
||||||
|
#[arg(long)]
|
||||||
|
pub no_enter: bool,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, clap::Args)]
|
#[derive(Debug, clap::Args)]
|
||||||
pub struct KillArgs {
|
pub struct KillArgs {
|
||||||
/// Session ID to kill.
|
/// Session ID to kill.
|
||||||
@@ -245,6 +262,40 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_send_joining_the_message_words() {
|
||||||
|
let cli =
|
||||||
|
Cli::try_parse_from(["maestro", "send", "planner", "run", "the", "tests"]).unwrap();
|
||||||
|
let Command::Send(a) = cli.command else {
|
||||||
|
panic!("expected send")
|
||||||
|
};
|
||||||
|
assert_eq!(a.session, "planner");
|
||||||
|
assert_eq!(a.text, vec!["run", "the", "tests"]);
|
||||||
|
assert!(!a.no_enter);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The message is trailing_var_arg, so a word starting with `-` inside it
|
||||||
|
/// is text, not a flag clap should reject.
|
||||||
|
#[test]
|
||||||
|
fn send_message_may_start_with_a_dash() {
|
||||||
|
let cli =
|
||||||
|
Cli::try_parse_from(["maestro", "send", "--no-enter", "planner", "--force"]).unwrap();
|
||||||
|
let Command::Send(a) = cli.command else {
|
||||||
|
panic!("expected send")
|
||||||
|
};
|
||||||
|
assert!(a.no_enter);
|
||||||
|
assert_eq!(a.text, vec!["--force"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn send_text_may_be_omitted_for_stdin() {
|
||||||
|
let cli = Cli::try_parse_from(["maestro", "send", "planner"]).unwrap();
|
||||||
|
let Command::Send(a) = cli.command else {
|
||||||
|
panic!("expected send")
|
||||||
|
};
|
||||||
|
assert!(a.text.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rejects_env_without_equals() {
|
fn rejects_env_without_equals() {
|
||||||
let err = Cli::try_parse_from([
|
let err = Cli::try_parse_from([
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ use maestro_protocol::DaemonClient;
|
|||||||
|
|
||||||
use crate::attach::{parse_detach_key, run_attach_url};
|
use crate::attach::{parse_detach_key, run_attach_url};
|
||||||
use crate::cli::Command;
|
use crate::cli::Command;
|
||||||
use crate::sessions::{run_kill, run_list, run_spawn};
|
use crate::sessions::{run_kill, run_list, run_send, run_spawn};
|
||||||
use maestro_config::{default_config_path, MaestroConfigFile};
|
use maestro_config::{default_config_path, MaestroConfigFile};
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
@@ -24,6 +24,10 @@ pub enum CliError {
|
|||||||
Attach(#[from] crate::attach::AttachError),
|
Attach(#[from] crate::attach::AttachError),
|
||||||
#[error("{0}")]
|
#[error("{0}")]
|
||||||
Hooks(#[from] maestro_hooks::HooksError),
|
Hooks(#[from] maestro_hooks::HooksError),
|
||||||
|
#[error("{0}")]
|
||||||
|
Resolve(#[from] crate::sessions::ResolveError),
|
||||||
|
#[error("nothing to send: pass the text as arguments or pipe it on stdin")]
|
||||||
|
NothingToSend,
|
||||||
#[error("could not determine the home directory ($HOME unset?)")]
|
#[error("could not determine the home directory ($HOME unset?)")]
|
||||||
NoHome,
|
NoHome,
|
||||||
#[error("{0}")]
|
#[error("{0}")]
|
||||||
@@ -82,6 +86,10 @@ pub async fn dispatch(
|
|||||||
run_kill(&daemon, &args).await?;
|
run_kill(&daemon, &args).await?;
|
||||||
println!("killed {}", args.session_id);
|
println!("killed {}", args.session_id);
|
||||||
}
|
}
|
||||||
|
Command::Send(args) => {
|
||||||
|
let id = run_send(&daemon, &args, &mut std::io::stdin()).await?;
|
||||||
|
println!("sent to {id}");
|
||||||
|
}
|
||||||
Command::Hooks(_) => unreachable!("hooks handled before the daemon client exists"),
|
Command::Hooks(_) => unreachable!("hooks handled before the daemon client exists"),
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -5,12 +5,13 @@ use std::io::Write;
|
|||||||
|
|
||||||
use maestro_protocol::display::abbreviate_dir;
|
use maestro_protocol::display::abbreviate_dir;
|
||||||
use maestro_protocol::{
|
use maestro_protocol::{
|
||||||
ActivityState, ClientError, CreateSessionRequest, DaemonClient, DaemonInfo, Session,
|
ActivityState, ClientError, CreateSessionRequest, DaemonClient, DaemonInfo, SendInputRequest,
|
||||||
SessionStatus, WorktreeSpec,
|
Session, SessionStatus, WorktreeSpec,
|
||||||
};
|
};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
use crate::cli::{KillArgs, ListArgs, SpawnArgs};
|
use crate::cli::{KillArgs, ListArgs, SendArgs, SpawnArgs};
|
||||||
|
use crate::dispatch::CliError;
|
||||||
|
|
||||||
/// `maestro list`
|
/// `maestro list`
|
||||||
pub async fn run_list(
|
pub async fn run_list(
|
||||||
@@ -66,6 +67,92 @@ pub async fn run_kill(client: &DaemonClient, args: &KillArgs) -> Result<(), Clie
|
|||||||
client.kill_session(args.session_id, args.force).await
|
client.kill_session(args.session_id, args.force).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Nothing on the daemon answers to what the operator named.
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum ResolveError {
|
||||||
|
#[error("no session matches {0:?} — run `maestro list` to see what is running")]
|
||||||
|
NoMatch(String),
|
||||||
|
#[error("{0:?} matches {1} sessions; use a session id instead")]
|
||||||
|
Ambiguous(String, usize),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Turn what the operator typed into a session id: a full ID, a unique ID
|
||||||
|
/// prefix, or a session's name (#125), matched case-insensitively. Names come
|
||||||
|
/// first — an operator who named a session after a hex string meant the name.
|
||||||
|
pub fn resolve_session(sessions: &[Session], needle: &str) -> Result<uuid::Uuid, ResolveError> {
|
||||||
|
let needle = needle.trim();
|
||||||
|
let by_name: Vec<&Session> = sessions
|
||||||
|
.iter()
|
||||||
|
.filter(|s| {
|
||||||
|
maestro_protocol::display::custom_name(s)
|
||||||
|
.is_some_and(|n| n.eq_ignore_ascii_case(needle))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
match by_name.len() {
|
||||||
|
1 => return Ok(by_name[0].id),
|
||||||
|
0 => {}
|
||||||
|
n => return Err(ResolveError::Ambiguous(needle.to_string(), n)),
|
||||||
|
}
|
||||||
|
|
||||||
|
let by_id: Vec<&Session> = sessions
|
||||||
|
.iter()
|
||||||
|
.filter(|s| {
|
||||||
|
s.id.to_string()
|
||||||
|
.to_ascii_lowercase()
|
||||||
|
.starts_with(&needle.to_ascii_lowercase())
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
match by_id.len() {
|
||||||
|
1 => Ok(by_id[0].id),
|
||||||
|
0 => Err(ResolveError::NoMatch(needle.to_string())),
|
||||||
|
n => Err(ResolveError::Ambiguous(needle.to_string(), n)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `maestro send`. Answers with the session that was typed into, so the
|
||||||
|
/// caller can name it once the daemon has accepted the text.
|
||||||
|
///
|
||||||
|
/// A bare ID skips the catalog fetch; anything else needs the list to match
|
||||||
|
/// against. Text comes from the arguments, or from stdin when there is none —
|
||||||
|
/// the shape a heredoc or a pipe wants.
|
||||||
|
#[allow(clippy::result_large_err)]
|
||||||
|
pub async fn run_send(
|
||||||
|
client: &DaemonClient,
|
||||||
|
args: &SendArgs,
|
||||||
|
stdin: &mut impl std::io::Read,
|
||||||
|
) -> Result<uuid::Uuid, CliError> {
|
||||||
|
let text = if args.text.is_empty() {
|
||||||
|
let mut buf = String::new();
|
||||||
|
stdin.read_to_string(&mut buf)?;
|
||||||
|
// One trailing newline is how a pipe ends, not part of the message —
|
||||||
|
// keeping it would submit the text twice.
|
||||||
|
buf.strip_suffix('\n')
|
||||||
|
.map(|t| t.strip_suffix('\r').unwrap_or(t))
|
||||||
|
.unwrap_or(&buf)
|
||||||
|
.to_string()
|
||||||
|
} else {
|
||||||
|
args.text.join(" ")
|
||||||
|
};
|
||||||
|
if text.is_empty() {
|
||||||
|
return Err(CliError::NothingToSend);
|
||||||
|
}
|
||||||
|
|
||||||
|
let id = match uuid::Uuid::parse_str(args.session.trim()) {
|
||||||
|
Ok(id) => id,
|
||||||
|
Err(_) => {
|
||||||
|
let sessions = client.list_sessions().await?;
|
||||||
|
resolve_session(&sessions, &args.session)?
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let req = SendInputRequest {
|
||||||
|
text,
|
||||||
|
submit: !args.no_enter,
|
||||||
|
};
|
||||||
|
client.send_input(id, &req).await?;
|
||||||
|
Ok(id)
|
||||||
|
}
|
||||||
|
|
||||||
#[allow(clippy::write_literal)]
|
#[allow(clippy::write_literal)]
|
||||||
fn print_session_table(out: &mut impl Write, sessions: &[Session], info: &DaemonInfo) {
|
fn print_session_table(out: &mut impl Write, sessions: &[Session], info: &DaemonInfo) {
|
||||||
if sessions.is_empty() {
|
if sessions.is_empty() {
|
||||||
@@ -218,6 +305,63 @@ mod tests {
|
|||||||
assert!(out.contains("bash [claude]"), "{out}");
|
assert!(out.contains("bash [claude]"), "{out}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn named(id: u128, name: &str) -> Session {
|
||||||
|
let mut s = session("/tmp", "claude", id);
|
||||||
|
s.metadata
|
||||||
|
.insert("name".into(), serde_json::Value::String(name.into()));
|
||||||
|
s
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolves_by_name_id_prefix_and_reports_what_it_cannot() {
|
||||||
|
use super::{resolve_session, ResolveError};
|
||||||
|
|
||||||
|
// Ids that differ in their first characters, so a short prefix is
|
||||||
|
// enough to pick one — the ergonomics the prefix match exists for.
|
||||||
|
let (a, b) = (1u128 << 124, 2u128 << 124);
|
||||||
|
let sessions = vec![named(a, "planner"), named(b, "builder")];
|
||||||
|
let one = Uuid::from_u128(a);
|
||||||
|
|
||||||
|
assert_eq!(resolve_session(&sessions, "planner").unwrap(), one);
|
||||||
|
// Names match case-insensitively and ignore surrounding space.
|
||||||
|
assert_eq!(resolve_session(&sessions, " Planner ").unwrap(), one);
|
||||||
|
// An id prefix works when it picks exactly one session.
|
||||||
|
let prefix = &one.to_string()[..8];
|
||||||
|
assert_eq!(resolve_session(&sessions, prefix).unwrap(), one);
|
||||||
|
assert!(matches!(
|
||||||
|
resolve_session(&sessions, "nope"),
|
||||||
|
Err(ResolveError::NoMatch(_))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Two sessions sharing a name is legal — the daemon never enforced
|
||||||
|
/// uniqueness — so `send` must refuse rather than pick one.
|
||||||
|
#[test]
|
||||||
|
fn an_ambiguous_name_is_refused() {
|
||||||
|
use super::{resolve_session, ResolveError};
|
||||||
|
|
||||||
|
let sessions = vec![named(1 << 124, "worker"), named(2 << 124, "worker")];
|
||||||
|
assert!(matches!(
|
||||||
|
resolve_session(&sessions, "worker"),
|
||||||
|
Err(ResolveError::Ambiguous(_, 2))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A session named after its own id prefix means the name: an operator
|
||||||
|
/// who typed a name they chose should not be routed by hex coincidence.
|
||||||
|
#[test]
|
||||||
|
fn names_win_over_id_prefixes() {
|
||||||
|
use super::resolve_session;
|
||||||
|
|
||||||
|
let two = Uuid::from_u128(2 << 124);
|
||||||
|
let prefix = two.to_string()[..8].to_string();
|
||||||
|
let sessions = vec![named(1 << 124, &prefix), session("/tmp", "bash", 2 << 124)];
|
||||||
|
assert_eq!(
|
||||||
|
resolve_session(&sessions, &prefix).unwrap(),
|
||||||
|
Uuid::from_u128(1 << 124)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn empty_sessions() {
|
fn empty_sessions() {
|
||||||
let mut buf = Vec::new();
|
let mut buf = Vec::new();
|
||||||
|
|||||||
@@ -108,3 +108,110 @@ async fn spawn_round_trips_through_the_daemon() {
|
|||||||
.expect("kill");
|
.expect("kill");
|
||||||
assert_eq!(mock.session_count(), 0);
|
assert_eq!(mock.session_count(), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn send_types_text_and_submits_by_default() {
|
||||||
|
let mock = MockDaemon::start().await;
|
||||||
|
let id = Uuid::from_u128(7);
|
||||||
|
mock.add_session(session(id, "/home/mock/work", "claude"));
|
||||||
|
|
||||||
|
let args = maestro_cli::cli::SendArgs {
|
||||||
|
session: id.to_string(),
|
||||||
|
text: vec!["run".into(), "the".into(), "tests".into()],
|
||||||
|
no_enter: false,
|
||||||
|
};
|
||||||
|
let sent = maestro_cli::sessions::run_send(&mock.client(), &args, &mut std::io::empty())
|
||||||
|
.await
|
||||||
|
.expect("send");
|
||||||
|
|
||||||
|
assert_eq!(sent, id);
|
||||||
|
let inputs = mock.inputs();
|
||||||
|
assert_eq!(inputs.len(), 1);
|
||||||
|
assert_eq!(inputs[0].0, id);
|
||||||
|
assert_eq!(inputs[0].1.text, "run the tests");
|
||||||
|
assert!(inputs[0].1.submit, "Enter is the default");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `--no-enter` leaves the text in the prompt; the flag has to survive the
|
||||||
|
/// whole way to the daemon or the message is sent anyway.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn send_without_enter_leaves_the_text_unsubmitted() {
|
||||||
|
let mock = MockDaemon::start().await;
|
||||||
|
let id = Uuid::from_u128(8);
|
||||||
|
mock.add_session(session(id, "/tmp", "bash"));
|
||||||
|
|
||||||
|
let args = maestro_cli::cli::SendArgs {
|
||||||
|
session: id.to_string(),
|
||||||
|
text: vec!["ls".into()],
|
||||||
|
no_enter: true,
|
||||||
|
};
|
||||||
|
maestro_cli::sessions::run_send(&mock.client(), &args, &mut std::io::empty())
|
||||||
|
.await
|
||||||
|
.expect("send");
|
||||||
|
|
||||||
|
assert!(!mock.inputs()[0].1.submit);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// No text arguments → the message comes from stdin, minus the trailing
|
||||||
|
/// newline a pipe or heredoc adds.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn send_reads_stdin_and_drops_its_trailing_newline() {
|
||||||
|
let mock = MockDaemon::start().await;
|
||||||
|
let id = Uuid::from_u128(9);
|
||||||
|
mock.add_session(session(id, "/tmp", "bash"));
|
||||||
|
|
||||||
|
let args = maestro_cli::cli::SendArgs {
|
||||||
|
session: id.to_string(),
|
||||||
|
text: Vec::new(),
|
||||||
|
no_enter: false,
|
||||||
|
};
|
||||||
|
let mut stdin = std::io::Cursor::new(b"multi\nline\n".to_vec());
|
||||||
|
maestro_cli::sessions::run_send(&mock.client(), &args, &mut stdin)
|
||||||
|
.await
|
||||||
|
.expect("send");
|
||||||
|
|
||||||
|
assert_eq!(mock.inputs()[0].1.text, "multi\nline");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A session named through `PATCH /api/sessions/:id` is addressable by that
|
||||||
|
/// name, so scripts don't have to carry a UUID around.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn send_resolves_a_session_by_name() {
|
||||||
|
let mock = MockDaemon::start().await;
|
||||||
|
let id = Uuid::from_u128(10);
|
||||||
|
let mut named = session(id, "/tmp", "claude");
|
||||||
|
named
|
||||||
|
.metadata
|
||||||
|
.insert("name".into(), serde_json::Value::String("planner".into()));
|
||||||
|
mock.add_session(named);
|
||||||
|
mock.add_session(session(Uuid::from_u128(11), "/tmp", "bash"));
|
||||||
|
|
||||||
|
let args = maestro_cli::cli::SendArgs {
|
||||||
|
session: "Planner".into(),
|
||||||
|
text: vec!["go".into()],
|
||||||
|
no_enter: false,
|
||||||
|
};
|
||||||
|
let sent = maestro_cli::sessions::run_send(&mock.client(), &args, &mut std::io::empty())
|
||||||
|
.await
|
||||||
|
.expect("send");
|
||||||
|
|
||||||
|
assert_eq!(sent, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn send_to_an_unknown_session_fails_without_typing_anything() {
|
||||||
|
let mock = MockDaemon::start().await;
|
||||||
|
mock.add_session(session(Uuid::from_u128(12), "/tmp", "bash"));
|
||||||
|
|
||||||
|
let args = maestro_cli::cli::SendArgs {
|
||||||
|
session: "nope".into(),
|
||||||
|
text: vec!["hi".into()],
|
||||||
|
no_enter: false,
|
||||||
|
};
|
||||||
|
let err = maestro_cli::sessions::run_send(&mock.client(), &args, &mut std::io::empty())
|
||||||
|
.await
|
||||||
|
.expect_err("should not resolve");
|
||||||
|
|
||||||
|
assert!(err.to_string().contains("no session matches"), "{err}");
|
||||||
|
assert!(mock.inputs().is_empty());
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ use axum::{Json, Router};
|
|||||||
use maestro_config::SpawnPreset;
|
use maestro_config::SpawnPreset;
|
||||||
use maestro_protocol::{
|
use maestro_protocol::{
|
||||||
routes, ApiError, CatalogEvent, CreateSessionRequest, DaemonInfo, GitRoot, NameBatch,
|
routes, ApiError, CatalogEvent, CreateSessionRequest, DaemonInfo, GitRoot, NameBatch,
|
||||||
RenameSessionRequest, ReportActivityRequest, Session, PROTOCOL_VERSION,
|
RenameSessionRequest, ReportActivityRequest, SendInputRequest, Session, PROTOCOL_VERSION,
|
||||||
};
|
};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@@ -44,6 +44,7 @@ pub fn router(state: LocalApiState) -> Router {
|
|||||||
Router::new()
|
Router::new()
|
||||||
.route(routes::SESSIONS, get(list_sessions).post(create_session))
|
.route(routes::SESSIONS, get(list_sessions).post(create_session))
|
||||||
.route(routes::patterns::SESSION_ACTIVITY, post(report_activity))
|
.route(routes::patterns::SESSION_ACTIVITY, post(report_activity))
|
||||||
|
.route(routes::patterns::SESSION_INPUT, post(send_input))
|
||||||
.route(
|
.route(
|
||||||
routes::patterns::SESSION,
|
routes::patterns::SESSION,
|
||||||
delete(kill_session).patch(rename_session),
|
delete(kill_session).patch(rename_session),
|
||||||
@@ -100,6 +101,9 @@ fn session_error_status(code: &str) -> StatusCode {
|
|||||||
"invalid_env" => StatusCode::BAD_REQUEST,
|
"invalid_env" => StatusCode::BAD_REQUEST,
|
||||||
// A guard refusal is the daemon doing its job, not a failure (#79).
|
// A guard refusal is the daemon doing its job, not a failure (#79).
|
||||||
"kill_guarded" => StatusCode::CONFLICT,
|
"kill_guarded" => StatusCode::CONFLICT,
|
||||||
|
// The session outlived its pane: nothing to type into, and nothing
|
||||||
|
// the daemon can do about it.
|
||||||
|
"not_running" => StatusCode::CONFLICT,
|
||||||
_ => StatusCode::BAD_GATEWAY,
|
_ => StatusCode::BAD_GATEWAY,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -247,6 +251,28 @@ async fn report_activity(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `POST /api/sessions/:id/input`: type text into a session's pane, then
|
||||||
|
/// press Enter unless the request turns `submit` off. Answers `204 No
|
||||||
|
/// Content`. A session that has exited is refused (409) rather than accepted
|
||||||
|
/// into nowhere.
|
||||||
|
async fn send_input(
|
||||||
|
State(s): State<LocalApiState>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
Json(body): Json<SendInputRequest>,
|
||||||
|
) -> Response {
|
||||||
|
match s.manager.send_input(id, &body.text, body.submit).await {
|
||||||
|
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
||||||
|
Err(e) => {
|
||||||
|
let info = e.to_error_info();
|
||||||
|
error_response(
|
||||||
|
session_error_status(&info.code),
|
||||||
|
info.code.as_str(),
|
||||||
|
info.message.as_str(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn error_response(status: StatusCode, code: &str, message: &str) -> Response {
|
fn error_response(status: StatusCode, code: &str, message: &str) -> Response {
|
||||||
(status, Json(ApiError::new(code, message))).into_response()
|
(status, Json(ApiError::new(code, message))).into_response()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,15 @@ pub const DEFAULT_LIVENESS_INTERVAL: Duration = Duration::from_secs(2);
|
|||||||
/// attempt, which is all #83 needs.
|
/// attempt, which is all #83 needs.
|
||||||
pub const DEFAULT_BLOCKED_WORK_INTERVAL: Duration = Duration::from_secs(15);
|
pub const DEFAULT_BLOCKED_WORK_INTERVAL: Duration = Duration::from_secs(15);
|
||||||
|
|
||||||
|
/// Gap between the text of a `send` and the Enter that submits it.
|
||||||
|
///
|
||||||
|
/// Agents that run a full-screen prompt (Claude Code, opencode) coalesce a
|
||||||
|
/// burst of input into one paste; a newline inside that burst becomes a line
|
||||||
|
/// break in the message instead of sending it. Landing the Enter as its own
|
||||||
|
/// write, after the reader has drained the text, is what makes the message
|
||||||
|
/// actually go.
|
||||||
|
const SUBMIT_DELAY: Duration = Duration::from_millis(150);
|
||||||
|
|
||||||
/// Per-session daemon-local bookkeeping that complements the public `Session`.
|
/// Per-session daemon-local bookkeeping that complements the public `Session`.
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct SessionRecord {
|
pub struct SessionRecord {
|
||||||
@@ -78,6 +87,11 @@ pub enum SessionError {
|
|||||||
NotFound(Uuid),
|
NotFound(Uuid),
|
||||||
#[error("invalid environment variable name: {0:?}")]
|
#[error("invalid environment variable name: {0:?}")]
|
||||||
InvalidEnv(String),
|
InvalidEnv(String),
|
||||||
|
/// The session exists but its pane is gone, so there is nothing to type
|
||||||
|
/// into. Writing to a dead session is a client bug worth reporting, unlike
|
||||||
|
/// a stray keystroke on an attach socket, which is dropped.
|
||||||
|
#[error("session is no longer running: {0}")]
|
||||||
|
NotRunning(Uuid),
|
||||||
/// The kill was refused (#79): the session's worktree holds unsaved work.
|
/// The kill was refused (#79): the session's worktree holds unsaved work.
|
||||||
/// Nothing happened — the session keeps running and the tree is untouched,
|
/// Nothing happened — the session keeps running and the tree is untouched,
|
||||||
/// so it stays the operator's handle on their own unfinished work.
|
/// so it stays the operator's handle on their own unfinished work.
|
||||||
@@ -94,6 +108,7 @@ impl SessionError {
|
|||||||
SessionError::Io(_) => "io_failed",
|
SessionError::Io(_) => "io_failed",
|
||||||
SessionError::NotFound(_) => "not_found",
|
SessionError::NotFound(_) => "not_found",
|
||||||
SessionError::InvalidEnv(_) => "invalid_env",
|
SessionError::InvalidEnv(_) => "invalid_env",
|
||||||
|
SessionError::NotRunning(_) => "not_running",
|
||||||
SessionError::KillGuarded(_) => "kill_guarded",
|
SessionError::KillGuarded(_) => "kill_guarded",
|
||||||
};
|
};
|
||||||
let unsaved_work = match self {
|
let unsaved_work = match self {
|
||||||
@@ -671,6 +686,33 @@ impl SessionManager {
|
|||||||
.map_err(SessionError::Tmux)
|
.map_err(SessionError::Tmux)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Type `text` into a session's pane, with an Enter after it when
|
||||||
|
/// `submit` is set.
|
||||||
|
///
|
||||||
|
/// Unlike [`Self::send_keys`], a dead session is an error: the caller
|
||||||
|
/// asked for text to land somewhere, and a silent success would let a
|
||||||
|
/// script believe an agent had been told something it never heard.
|
||||||
|
pub async fn send_input(&self, id: Uuid, text: &str, submit: bool) -> Result<(), SessionError> {
|
||||||
|
match self.status(id).await {
|
||||||
|
None => return Err(SessionError::NotFound(id)),
|
||||||
|
Some(SessionStatus::Dead) => return Err(SessionError::NotRunning(id)),
|
||||||
|
Some(SessionStatus::Running) => {}
|
||||||
|
}
|
||||||
|
let tmux = self.inner.tmux.clone();
|
||||||
|
let bytes = text.as_bytes().to_vec();
|
||||||
|
tmux_blocking(move || tmux.send_keys(id, &bytes))
|
||||||
|
.await
|
||||||
|
.map_err(SessionError::Tmux)?;
|
||||||
|
if submit {
|
||||||
|
tokio::time::sleep(SUBMIT_DELAY).await;
|
||||||
|
let tmux = self.inner.tmux.clone();
|
||||||
|
tmux_blocking(move || tmux.send_keys(id, b"\r"))
|
||||||
|
.await
|
||||||
|
.map_err(SessionError::Tmux)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Resize a session's window. No-op for a dead session, as with
|
/// Resize a session's window. No-op for a dead session, as with
|
||||||
/// [`Self::send_keys`].
|
/// [`Self::send_keys`].
|
||||||
pub async fn resize(&self, id: Uuid, cols: u16, rows: u16) -> Result<(), SessionError> {
|
pub async fn resize(&self, id: Uuid, cols: u16, rows: u16) -> Result<(), SessionError> {
|
||||||
|
|||||||
@@ -1250,6 +1250,143 @@ async fn cli_list_reads_from_daemon() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `maestro send` end to end: the shipped binary, a real daemon, a real tmux
|
||||||
|
/// pane. The shell only runs the command if the Enter arrived as its own
|
||||||
|
/// write after the text, so the file appearing is the whole path working.
|
||||||
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
async fn cli_send_types_a_command_into_a_live_shell() {
|
||||||
|
let (daemon, client) = bring_up().await;
|
||||||
|
let cwd = TempDir::new().unwrap();
|
||||||
|
let sess = client
|
||||||
|
.spawn_session(&create_req(cwd.path(), "bash --norc --noprofile"))
|
||||||
|
.await
|
||||||
|
.expect("spawn bash");
|
||||||
|
|
||||||
|
let home = TempDir::new().unwrap();
|
||||||
|
let cfg_path = home.path().join("config.toml");
|
||||||
|
let out = run_cli(
|
||||||
|
&["send", &sess.id.to_string(), "touch", "marker.txt"],
|
||||||
|
&[
|
||||||
|
("HOME", home.path().to_str().unwrap()),
|
||||||
|
("MAESTRO_CONFIG", cfg_path.to_str().unwrap()),
|
||||||
|
("MAESTRO_DAEMON_URL", &daemon.http_base()),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
out.status.success(),
|
||||||
|
"send failed: {}",
|
||||||
|
String::from_utf8_lossy(&out.stderr)
|
||||||
|
);
|
||||||
|
|
||||||
|
let marker = cwd.path().join("marker.txt");
|
||||||
|
wait_for_async(
|
||||||
|
"the shell runs the sent command",
|
||||||
|
Duration::from_secs(10),
|
||||||
|
|| {
|
||||||
|
let marker = marker.clone();
|
||||||
|
async move { marker.exists() }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
client.kill_session(sess.id).await.expect("kill");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `--no-enter` fills the prompt without submitting: the command must sit
|
||||||
|
/// there unrun. Same setup as the test above, so the only difference is the
|
||||||
|
/// flag.
|
||||||
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
async fn send_without_enter_does_not_run_the_command() {
|
||||||
|
let (daemon, client) = bring_up().await;
|
||||||
|
let cwd = TempDir::new().unwrap();
|
||||||
|
let sess = client
|
||||||
|
.spawn_session(&create_req(cwd.path(), "bash --norc --noprofile"))
|
||||||
|
.await
|
||||||
|
.expect("spawn bash");
|
||||||
|
|
||||||
|
let home = TempDir::new().unwrap();
|
||||||
|
let out = run_cli(
|
||||||
|
&[
|
||||||
|
"send",
|
||||||
|
"--no-enter",
|
||||||
|
&sess.id.to_string(),
|
||||||
|
"touch",
|
||||||
|
"unsubmitted.txt",
|
||||||
|
],
|
||||||
|
&[
|
||||||
|
("HOME", home.path().to_str().unwrap()),
|
||||||
|
(
|
||||||
|
"MAESTRO_CONFIG",
|
||||||
|
home.path().join("c.toml").to_str().unwrap(),
|
||||||
|
),
|
||||||
|
("MAESTRO_DAEMON_URL", &daemon.http_base()),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
out.status.success(),
|
||||||
|
"send failed: {}",
|
||||||
|
String::from_utf8_lossy(&out.stderr)
|
||||||
|
);
|
||||||
|
|
||||||
|
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||||
|
assert!(
|
||||||
|
!cwd.path().join("unsubmitted.txt").exists(),
|
||||||
|
"the command ran despite --no-enter"
|
||||||
|
);
|
||||||
|
|
||||||
|
client.kill_session(sess.id).await.expect("kill");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A session whose command exited still exists in the catalog, but its pane
|
||||||
|
/// is gone. Typing into it is refused instead of being swallowed — a script
|
||||||
|
/// that sends an instruction to a dead agent has to hear about it.
|
||||||
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
async fn send_to_a_dead_session_is_refused() {
|
||||||
|
use maestro_protocol::{SendInputRequest, SessionStatus};
|
||||||
|
let (_daemon, client) = bring_up().await;
|
||||||
|
let cwd = TempDir::new().unwrap();
|
||||||
|
let sess = client
|
||||||
|
.spawn_session(&create_req(cwd.path(), "true"))
|
||||||
|
.await
|
||||||
|
.expect("spawn true");
|
||||||
|
|
||||||
|
wait_for_async(
|
||||||
|
"the session to be seen dead",
|
||||||
|
Duration::from_secs(15),
|
||||||
|
|| {
|
||||||
|
let client = client.clone();
|
||||||
|
let id = sess.id;
|
||||||
|
async move {
|
||||||
|
client
|
||||||
|
.list_sessions()
|
||||||
|
.await
|
||||||
|
.map(|ss| {
|
||||||
|
ss.iter()
|
||||||
|
.any(|s| s.id == id && s.status == SessionStatus::Dead)
|
||||||
|
})
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let err = client
|
||||||
|
.daemon()
|
||||||
|
.send_input(
|
||||||
|
sess.id,
|
||||||
|
&SendInputRequest {
|
||||||
|
text: "hello".into(),
|
||||||
|
submit: true,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect_err("a dead session cannot be typed into");
|
||||||
|
assert!(
|
||||||
|
err.to_string().contains("no longer running"),
|
||||||
|
"unexpected error: {err}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test(flavor = "multi_thread")]
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
async fn cli_list_fails_when_daemon_is_down() {
|
async fn cli_list_fails_when_daemon_is_down() {
|
||||||
let dead_daemon = format!("http://127.0.0.1:{}", maestro_e2e::util::free_port());
|
let dead_daemon = format!("http://127.0.0.1:{}", maestro_e2e::util::free_port());
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ use uuid::Uuid;
|
|||||||
use crate::routes::{self, UrlError};
|
use crate::routes::{self, UrlError};
|
||||||
use crate::{
|
use crate::{
|
||||||
ApiError, BranchInfo, CreateSessionRequest, DaemonInfo, DirListing, ErrorInfo, GitRoot,
|
ApiError, BranchInfo, CreateSessionRequest, DaemonInfo, DirListing, ErrorInfo, GitRoot,
|
||||||
Project, RecentDirs, RenameSessionRequest, ReportActivityRequest, Session,
|
Project, RecentDirs, RenameSessionRequest, ReportActivityRequest, SendInputRequest, Session,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Time budget for establishing a TCP connection to the daemon.
|
/// Time budget for establishing a TCP connection to the daemon.
|
||||||
@@ -155,6 +155,17 @@ impl DaemonClient {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `POST /api/sessions/:id/input`: type text into a session's pane, with
|
||||||
|
/// an Enter after it unless `submit` is off. Answers `204 No Content`; a
|
||||||
|
/// session that has exited is refused rather than silently accepted.
|
||||||
|
pub async fn send_input(&self, id: Uuid, req: &SendInputRequest) -> Result<(), ClientError> {
|
||||||
|
let resp = self
|
||||||
|
.send(Method::POST, &routes::session_input(id), &[], Some(req))
|
||||||
|
.await?;
|
||||||
|
check(resp).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn daemon_info(&self) -> Result<DaemonInfo, ClientError> {
|
pub async fn daemon_info(&self) -> Result<DaemonInfo, ClientError> {
|
||||||
self.get(routes::INFO, &[]).await
|
self.get(routes::INFO, &[]).await
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,15 @@ use std::path::{Path, PathBuf};
|
|||||||
|
|
||||||
use crate::Session;
|
use crate::Session;
|
||||||
|
|
||||||
|
/// The name a session was given (#125), if any. `None` for one that was never
|
||||||
|
/// renamed — clients fall back to the command.
|
||||||
|
pub fn custom_name(s: &Session) -> Option<&str> {
|
||||||
|
match s.metadata.get("name") {
|
||||||
|
Some(serde_json::Value::String(name)) => Some(name.as_str()),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Collapse a daemon's home dir to `~`. `home` empty leaves the path
|
/// Collapse a daemon's home dir to `~`. `home` empty leaves the path
|
||||||
/// untouched. Mirrors the web's `abbreviateCwd`.
|
/// untouched. Mirrors the web's `abbreviateCwd`.
|
||||||
pub fn abbreviate_dir(path: &Path, home: &str) -> String {
|
pub fn abbreviate_dir(path: &Path, home: &str) -> String {
|
||||||
|
|||||||
@@ -512,6 +512,24 @@ pub struct NameBatch {
|
|||||||
pub names: Vec<String>,
|
pub names: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// HTTP `POST /api/sessions/:id/input` body: text typed into a session's
|
||||||
|
/// pane, as if the operator had typed it on an attached terminal.
|
||||||
|
///
|
||||||
|
/// `submit` presses Enter after the text — what makes `maestro send` a way to
|
||||||
|
/// give an agent an instruction rather than only to fill its prompt. It goes
|
||||||
|
/// out as its own write a beat later, because a TUI agent that reads the text
|
||||||
|
/// as one paste would otherwise swallow the newline into the message.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct SendInputRequest {
|
||||||
|
pub text: String,
|
||||||
|
#[serde(default = "submit_default")]
|
||||||
|
pub submit: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn submit_default() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
/// Longest session name the daemon stores (#125), in characters. A name is a
|
/// Longest session name the daemon stores (#125), in characters. A name is a
|
||||||
/// sidebar card's line 1; past this it only truncates on every client anyway.
|
/// sidebar card's line 1; past this it only truncates on every client anyway.
|
||||||
pub const MAX_SESSION_NAME_LEN: usize = 64;
|
pub const MAX_SESSION_NAME_LEN: usize = 64;
|
||||||
|
|||||||
@@ -49,6 +49,11 @@ pub fn session_activity(id: Uuid) -> String {
|
|||||||
format!("{SESSIONS}/{id}/activity")
|
format!("{SESSIONS}/{id}/activity")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `POST /api/sessions/:id/input`: type text into a session's pane.
|
||||||
|
pub fn session_input(id: Uuid) -> String {
|
||||||
|
format!("{SESSIONS}/{id}/input")
|
||||||
|
}
|
||||||
|
|
||||||
/// `WS /api/attach/:id` — raw PTY bytes.
|
/// `WS /api/attach/:id` — raw PTY bytes.
|
||||||
pub fn attach(id: Uuid) -> String {
|
pub fn attach(id: Uuid) -> String {
|
||||||
format!("{API_PREFIX}/attach/{id}")
|
format!("{API_PREFIX}/attach/{id}")
|
||||||
@@ -59,6 +64,7 @@ pub fn attach(id: Uuid) -> String {
|
|||||||
pub mod patterns {
|
pub mod patterns {
|
||||||
pub const SESSION: &str = "/api/sessions/:id";
|
pub const SESSION: &str = "/api/sessions/:id";
|
||||||
pub const SESSION_ACTIVITY: &str = "/api/sessions/:id/activity";
|
pub const SESSION_ACTIVITY: &str = "/api/sessions/:id/activity";
|
||||||
|
pub const SESSION_INPUT: &str = "/api/sessions/:id/input";
|
||||||
pub const ATTACH: &str = "/api/attach/:id";
|
pub const ATTACH: &str = "/api/attach/:id";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,6 +176,10 @@ mod tests {
|
|||||||
session_activity(id()),
|
session_activity(id()),
|
||||||
"/api/sessions/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50/activity"
|
"/api/sessions/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50/activity"
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
session_input(id()),
|
||||||
|
"/api/sessions/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50/input"
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
attach(id()),
|
attach(id()),
|
||||||
"/api/attach/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50"
|
"/api/attach/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50"
|
||||||
@@ -192,6 +202,10 @@ mod tests {
|
|||||||
patterns::SESSION_ACTIVITY.trim_end_matches(":id/activity"),
|
patterns::SESSION_ACTIVITY.trim_end_matches(":id/activity"),
|
||||||
session(id()).trim_end_matches(&id().to_string())
|
session(id()).trim_end_matches(&id().to_string())
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
patterns::SESSION_INPUT.trim_end_matches(":id/input"),
|
||||||
|
session(id()).trim_end_matches(&id().to_string())
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
patterns::ATTACH.trim_end_matches(":id"),
|
patterns::ATTACH.trim_end_matches(":id"),
|
||||||
attach(id()).trim_end_matches(&id().to_string())
|
attach(id()).trim_end_matches(&id().to_string())
|
||||||
|
|||||||
@@ -16,13 +16,13 @@ use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
|
|||||||
use axum::extract::{Path, Query, State};
|
use axum::extract::{Path, Query, State};
|
||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
use axum::response::{IntoResponse, Response};
|
use axum::response::{IntoResponse, Response};
|
||||||
use axum::routing::{any, delete, get};
|
use axum::routing::{any, delete, get, post};
|
||||||
use axum::{Json, Router};
|
use axum::{Json, Router};
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use futures_util::{SinkExt, StreamExt};
|
use futures_util::{SinkExt, StreamExt};
|
||||||
use maestro_protocol::{
|
use maestro_protocol::{
|
||||||
routes, ApiError, CatalogEvent, CreateSessionRequest, DaemonClient, DaemonInfo, DirListing,
|
routes, ApiError, CatalogEvent, CreateSessionRequest, DaemonClient, DaemonInfo, DirListing,
|
||||||
GitRoot, RecentDirs, Session, SessionStatus, PROTOCOL_VERSION,
|
GitRoot, RecentDirs, SendInputRequest, Session, SessionStatus, PROTOCOL_VERSION,
|
||||||
};
|
};
|
||||||
use tokio::net::TcpListener;
|
use tokio::net::TcpListener;
|
||||||
use tokio::sync::{broadcast, oneshot};
|
use tokio::sync::{broadcast, oneshot};
|
||||||
@@ -55,6 +55,8 @@ struct Inner {
|
|||||||
info: Option<DaemonInfo>,
|
info: Option<DaemonInfo>,
|
||||||
/// When set, every route answers with this instead of its normal response.
|
/// When set, every route answers with this instead of its normal response.
|
||||||
forced_error: Option<(u16, ApiError)>,
|
forced_error: Option<(u16, ApiError)>,
|
||||||
|
/// Everything `POST /api/sessions/:id/input` was asked to type, in order.
|
||||||
|
inputs: Vec<(Uuid, SendInputRequest)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@@ -86,6 +88,7 @@ impl MockDaemon {
|
|||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
.route(routes::SESSIONS, get(list_sessions).post(create_session))
|
.route(routes::SESSIONS, get(list_sessions).post(create_session))
|
||||||
.route(routes::patterns::SESSION, delete(kill_session))
|
.route(routes::patterns::SESSION, delete(kill_session))
|
||||||
|
.route(routes::patterns::SESSION_INPUT, post(send_input))
|
||||||
.route(routes::INFO, get(daemon_info))
|
.route(routes::INFO, get(daemon_info))
|
||||||
.route(routes::DIRS, get(list_dir))
|
.route(routes::DIRS, get(list_dir))
|
||||||
.route(routes::RECENT_DIRS, get(recent_dirs))
|
.route(routes::RECENT_DIRS, get(recent_dirs))
|
||||||
@@ -167,6 +170,11 @@ impl MockDaemon {
|
|||||||
self.state.inner.lock().expect("poisoned").forced_error = None;
|
self.state.inner.lock().expect("poisoned").forced_error = None;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Every `send` this daemon received, oldest first.
|
||||||
|
pub fn inputs(&self) -> Vec<(Uuid, SendInputRequest)> {
|
||||||
|
self.state.inner.lock().expect("poisoned").inputs.clone()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn session_count(&self) -> usize {
|
pub fn session_count(&self) -> usize {
|
||||||
self.state.inner.lock().expect("poisoned").sessions.len()
|
self.state.inner.lock().expect("poisoned").sessions.len()
|
||||||
}
|
}
|
||||||
@@ -233,6 +241,28 @@ async fn kill_session(State(s): State<AppState>, Path(id): Path<Uuid>) -> Respon
|
|||||||
StatusCode::NO_CONTENT.into_response()
|
StatusCode::NO_CONTENT.into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Records the text instead of typing it — the subject under test is the
|
||||||
|
/// client's request, not tmux.
|
||||||
|
async fn send_input(
|
||||||
|
State(s): State<AppState>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
Json(req): Json<SendInputRequest>,
|
||||||
|
) -> Response {
|
||||||
|
if let Some(r) = forced(&s) {
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
let mut guard = s.inner.lock().expect("poisoned");
|
||||||
|
if !guard.sessions.iter().any(|x| x.id == id) {
|
||||||
|
return (
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
Json(ApiError::new("not_found", "no such session on this daemon")),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
guard.inputs.push((id, req));
|
||||||
|
StatusCode::NO_CONTENT.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
async fn daemon_info(State(s): State<AppState>) -> Response {
|
async fn daemon_info(State(s): State<AppState>) -> Response {
|
||||||
if let Some(r) = forced(&s) {
|
if let Some(r) = forced(&s) {
|
||||||
return r;
|
return r;
|
||||||
|
|||||||
@@ -57,6 +57,10 @@ enum Command {
|
|||||||
/// Kill a session by ID.
|
/// Kill a session by ID.
|
||||||
#[cfg(feature = "cli")]
|
#[cfg(feature = "cli")]
|
||||||
Kill(maestro_cli::cli::KillArgs),
|
Kill(maestro_cli::cli::KillArgs),
|
||||||
|
/// Type text into a running session, as if it had been typed on an
|
||||||
|
/// attached terminal.
|
||||||
|
#[cfg(feature = "cli")]
|
||||||
|
Send(maestro_cli::cli::SendArgs),
|
||||||
/// Install or update the agent activity hook (#106).
|
/// Install or update the agent activity hook (#106).
|
||||||
#[cfg(feature = "cli")]
|
#[cfg(feature = "cli")]
|
||||||
Hooks(maestro_cli::cli::HooksArgs),
|
Hooks(maestro_cli::cli::HooksArgs),
|
||||||
@@ -144,6 +148,15 @@ async fn dispatch(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
.await?
|
.await?
|
||||||
}
|
}
|
||||||
#[cfg(feature = "cli")]
|
#[cfg(feature = "cli")]
|
||||||
|
Command::Send(args) => {
|
||||||
|
maestro_cli::dispatch(
|
||||||
|
maestro_cli::cli::Command::Send(args),
|
||||||
|
cli.config,
|
||||||
|
cli.daemon_url,
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
}
|
||||||
|
#[cfg(feature = "cli")]
|
||||||
Command::Hooks(args) => {
|
Command::Hooks(args) => {
|
||||||
maestro_cli::dispatch(
|
maestro_cli::dispatch(
|
||||||
maestro_cli::cli::Command::Hooks(args),
|
maestro_cli::cli::Command::Hooks(args),
|
||||||
@@ -190,6 +203,7 @@ fn is_cli_subcommand(command: &Option<Command>) -> bool {
|
|||||||
| Some(Command::Spawn(_))
|
| Some(Command::Spawn(_))
|
||||||
| Some(Command::Attach(_))
|
| Some(Command::Attach(_))
|
||||||
| Some(Command::Kill(_))
|
| Some(Command::Kill(_))
|
||||||
|
| Some(Command::Send(_))
|
||||||
| Some(Command::Hooks(_))
|
| Some(Command::Hooks(_))
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user