Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bb6b885c68 | ||
|
|
6ac137d432 |
@@ -14,6 +14,7 @@ maestro daemon tmux wrapper + session API + SPA on 127.0.0.1:7070
|
||||
├── DELETE /api/sessions/:id kill
|
||||
├── POST /api/sessions/:id/input type text into a session
|
||||
├── POST /api/sessions/:id/prompt an agent reports its first prompt (#131)
|
||||
├── POST /api/sessions/:id/resume respawn a dormant session, same id (#166)
|
||||
├── GET /api/info hostname, version, home dir, spawn presets
|
||||
├── GET /api/dirs directory listing (spawn autocomplete)
|
||||
├── GET /api/recent-dirs spawn history
|
||||
|
||||
@@ -25,6 +25,7 @@ pub mod proc;
|
||||
pub mod projects;
|
||||
pub mod pty;
|
||||
pub mod release;
|
||||
pub mod resume;
|
||||
pub mod session;
|
||||
pub mod sidecar;
|
||||
pub mod spawn_history;
|
||||
|
||||
@@ -52,6 +52,7 @@ pub fn router(state: LocalApiState) -> Router {
|
||||
.route(routes::patterns::SESSION_ACTIVITY, post(report_activity))
|
||||
.route(routes::patterns::SESSION_INPUT, post(send_input))
|
||||
.route(routes::patterns::SESSION_FOCUS, post(set_focus))
|
||||
.route(routes::patterns::SESSION_RESUME, post(resume_session))
|
||||
.route(
|
||||
routes::patterns::SESSION,
|
||||
delete(kill_session).patch(rename_session),
|
||||
@@ -170,6 +171,9 @@ fn session_error_status(code: &str) -> StatusCode {
|
||||
// The session outlived its pane: nothing to type into, and nothing
|
||||
// the daemon can do about it.
|
||||
"not_running" => StatusCode::CONFLICT,
|
||||
// Resume refusals (#166): the session exists and the request was
|
||||
// well-formed, it just cannot be brought back in this state.
|
||||
"already_running" | "cwd_missing" => StatusCode::CONFLICT,
|
||||
_ => StatusCode::BAD_GATEWAY,
|
||||
}
|
||||
}
|
||||
@@ -368,6 +372,27 @@ async fn set_focus(
|
||||
}
|
||||
}
|
||||
|
||||
/// `POST /api/sessions/:id/resume` (#166): respawn a session that has no pane
|
||||
/// under its own id and sidecar, so its name, `created_at`, env and worktree
|
||||
/// ownership survive. Answers with the updated session, which also goes out as
|
||||
/// `SessionUpdated` to every events subscriber.
|
||||
///
|
||||
/// Refused with `409` when the session is already running or its `cwd` is
|
||||
/// gone; `404` when there is no such session.
|
||||
async fn resume_session(State(s): State<LocalApiState>, Path(id): Path<Uuid>) -> Response {
|
||||
match s.manager.resume(id, &s.presets).await {
|
||||
Ok(session) => (StatusCode::OK, Json(session)).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 {
|
||||
(status, Json(ApiError::new(code, message))).into_response()
|
||||
}
|
||||
@@ -410,6 +435,16 @@ async fn attach_ws(
|
||||
ws: axum::extract::WebSocketUpgrade,
|
||||
) -> Result<Response, Response> {
|
||||
let Some((replay, rx)) = s.manager.subscribe_pty_with_replay(id).await else {
|
||||
// #166: a session the daemon knows but has no bridge for — a dormant
|
||||
// one — is not missing, it is not running. Reporting it as unknown
|
||||
// left the client unable to tell "gone" from "resumable".
|
||||
if s.manager.has(id).await {
|
||||
return Err(error_response(
|
||||
StatusCode::CONFLICT,
|
||||
"not_attachable",
|
||||
"session has no live pane; resume it first",
|
||||
));
|
||||
}
|
||||
return Err(error_response(
|
||||
StatusCode::NOT_FOUND,
|
||||
"unknown_session",
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
//! Choosing the command a resumed session comes back on (#166).
|
||||
//!
|
||||
//! A session records the command it was spawned with. Bringing it back means
|
||||
//! running that preset's `resume_command` (#165) instead, so an agent picks up
|
||||
//! its old conversation rather than starting a fresh one. Everything else
|
||||
//! about the session — id, sidecar, name, `created_at`, env, worktree — is
|
||||
//! untouched, so only the command is decided here.
|
||||
|
||||
use maestro_protocol::{Session, SpawnPreset};
|
||||
|
||||
/// The command to respawn `session` with.
|
||||
///
|
||||
/// The preset is matched on the command the session was spawned with, which is
|
||||
/// what the sidecar holds. A session with no matching preset, a preset with no
|
||||
/// `resume_command`, or a `resume_command` needing an agent session id the
|
||||
/// session never reported all fall back to the original command: respawning
|
||||
/// what the operator asked for is always honest, where a half-filled resume
|
||||
/// template is not.
|
||||
pub fn resume_command(session: &Session, presets: &[SpawnPreset]) -> String {
|
||||
presets
|
||||
.iter()
|
||||
.find(|p| p.command == session.command)
|
||||
.and_then(|p| p.resume_command_for(session.agent_session_id.as_deref()))
|
||||
.unwrap_or_else(|| session.command.clone())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::HashMap;
|
||||
|
||||
fn presets() -> Vec<SpawnPreset> {
|
||||
vec![
|
||||
SpawnPreset {
|
||||
id: "claude".into(),
|
||||
label: "Claude".into(),
|
||||
command: "claude".into(),
|
||||
resume_command: Some("claude --resume {agent_session}".into()),
|
||||
},
|
||||
SpawnPreset {
|
||||
id: "zsh".into(),
|
||||
label: "Shell".into(),
|
||||
command: "zsh".into(),
|
||||
resume_command: None,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
fn session(command: &str, agent_session_id: Option<&str>) -> Session {
|
||||
Session {
|
||||
id: uuid::Uuid::new_v4(),
|
||||
cwd: "/tmp".into(),
|
||||
command: command.into(),
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
status: maestro_protocol::SessionStatus::Dormant,
|
||||
created_at: chrono::Utc::now(),
|
||||
foreground: None,
|
||||
activity: maestro_protocol::ActivityState::Idle,
|
||||
activity_detail: None,
|
||||
last_activity_at: chrono::Utc::now(),
|
||||
activity_since: chrono::Utc::now(),
|
||||
current_cwd: None,
|
||||
blocked_work: None,
|
||||
last_focused_at: None,
|
||||
agent_session_id: agent_session_id.map(str::to_string),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uses_the_matching_presets_resume_command() {
|
||||
assert_eq!(
|
||||
resume_command(&session("claude", Some("conv-7")), &presets()),
|
||||
"claude --resume conv-7"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn falls_back_when_the_agent_session_id_is_unknown() {
|
||||
assert_eq!(
|
||||
resume_command(&session("claude", None), &presets()),
|
||||
"claude"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn falls_back_for_a_preset_without_a_resume_command() {
|
||||
assert_eq!(resume_command(&session("zsh", None), &presets()), "zsh");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn falls_back_when_no_preset_matches() {
|
||||
assert_eq!(
|
||||
resume_command(&session("bash --noprofile", Some("conv-7")), &presets()),
|
||||
"bash --noprofile"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -112,6 +112,15 @@ pub enum SessionError {
|
||||
/// a stray keystroke on an attach socket, which is dropped.
|
||||
#[error("session is no longer running: {0}")]
|
||||
NotRunning(Uuid),
|
||||
/// A resume (#166) was asked for a session that is already running. There
|
||||
/// is nothing to bring back, and respawning would strand the pane that is
|
||||
/// already there.
|
||||
#[error("session is already running: {0}")]
|
||||
AlreadyRunning(Uuid),
|
||||
/// A resume (#166) was asked for a session whose working directory is
|
||||
/// gone. Nothing can be resumed in a directory that no longer exists.
|
||||
#[error("session working directory no longer exists: {0}")]
|
||||
CwdMissing(PathBuf),
|
||||
/// The kill was refused (#79): the session's worktree holds unsaved work.
|
||||
/// Nothing happened — the session keeps running and the tree is untouched,
|
||||
/// so it stays the operator's handle on their own unfinished work.
|
||||
@@ -130,6 +139,8 @@ impl SessionError {
|
||||
SessionError::InvalidEnv(_) => "invalid_env",
|
||||
SessionError::NotRunning(_) => "not_running",
|
||||
SessionError::KillGuarded(_) => "kill_guarded",
|
||||
SessionError::AlreadyRunning(_) => "already_running",
|
||||
SessionError::CwdMissing(_) => "cwd_missing",
|
||||
};
|
||||
let unsaved_work = match self {
|
||||
SessionError::KillGuarded(b) => Some(*b),
|
||||
@@ -586,6 +597,112 @@ impl SessionManager {
|
||||
Ok(session)
|
||||
}
|
||||
|
||||
/// Bring a session that has no pane back to life (#166).
|
||||
///
|
||||
/// The session comes back under the **same** uuid and the same sidecar, so
|
||||
/// `metadata.name`, `created_at`, `env` and worktree ownership all survive
|
||||
/// — only the tmux session and the PTY bridge are new. That is the whole
|
||||
/// point: spawning a fresh session at the same path instead produces a
|
||||
/// stranger with no worktree to clean up on kill.
|
||||
///
|
||||
/// The command is [`crate::resume::resume_command`]: the matching preset's
|
||||
/// `resume_command` (#165) with the session's own agent conversation id
|
||||
/// substituted in, falling back to the command the session was spawned
|
||||
/// with. `Session::command` keeps the original either way, so a second
|
||||
/// resume still resolves the same preset.
|
||||
///
|
||||
/// Refused for a session that is already running, and for one whose `cwd`
|
||||
/// has disappeared — neither is something a respawn could fix.
|
||||
pub async fn resume(
|
||||
&self,
|
||||
id: Uuid,
|
||||
presets: &[maestro_protocol::SpawnPreset],
|
||||
) -> Result<Session, SessionError> {
|
||||
let mut session = {
|
||||
let sessions = self.inner.sessions.lock().await;
|
||||
let rec = sessions.get(&id).ok_or(SessionError::NotFound(id))?;
|
||||
if rec.session.status == SessionStatus::Running {
|
||||
return Err(SessionError::AlreadyRunning(id));
|
||||
}
|
||||
rec.session.clone()
|
||||
};
|
||||
if !session.cwd.exists() {
|
||||
return Err(SessionError::CwdMissing(session.cwd.clone()));
|
||||
}
|
||||
let command = crate::resume::resume_command(&session, presets);
|
||||
|
||||
// Same fifo path as the original spawn: it is derived from the id, and
|
||||
// `mkfifo` tolerates the stale node the previous run left behind.
|
||||
let fifo_path = self.inner.fifo_dir.join(format!("{id}.fifo"));
|
||||
let bridge =
|
||||
PtyBridge::start_with_replay(fifo_path.clone(), self.inner.replay_bytes).await?;
|
||||
let mut session_env = sanitize_env(&session.env)?;
|
||||
session_env.insert("MAESTRO_SESSION_ID".into(), id.to_string());
|
||||
if let Some(url) = self
|
||||
.inner
|
||||
.daemon_url
|
||||
.lock()
|
||||
.expect("daemon_url mutex")
|
||||
.clone()
|
||||
{
|
||||
session_env.insert("MAESTRO_URL".into(), url);
|
||||
}
|
||||
let spawned = {
|
||||
let tmux = self.inner.tmux.clone();
|
||||
let cwd = session.cwd.clone();
|
||||
let command = command.clone();
|
||||
let sink = pipe_pane_command(&fifo_path);
|
||||
tmux_blocking(move || tmux.new_session_piped(id, &cwd, &command, &session_env, &sink))
|
||||
.await
|
||||
};
|
||||
if let Err(e) = spawned {
|
||||
// `bridge` drops here, closing the FIFO reader. Nothing else was
|
||||
// touched, so the session stays exactly as dormant as it was.
|
||||
return Err(SessionError::Tmux(e));
|
||||
}
|
||||
|
||||
session.status = SessionStatus::Running;
|
||||
// A resumed session is producing its initial output again; the
|
||||
// watcher refines this on its next tick, exactly as after a spawn.
|
||||
session.foreground = None;
|
||||
session.activity = ActivityState::Active;
|
||||
session.activity_detail = None;
|
||||
session.last_activity_at = Utc::now();
|
||||
session.activity_since = Utc::now();
|
||||
session.current_cwd = None;
|
||||
session.last_focused_at = None;
|
||||
|
||||
if let Err(e) = self.inner.sidecar.write(&session) {
|
||||
// tmux already spawned; kill it rather than leave a pane the
|
||||
// sidecar does not describe.
|
||||
let tmux = self.inner.tmux.clone();
|
||||
let _ = tmux_blocking(move || tmux.kill_session(id)).await;
|
||||
return Err(SessionError::Sidecar(e));
|
||||
}
|
||||
|
||||
{
|
||||
let mut sessions = self.inner.sessions.lock().await;
|
||||
let Some(rec) = sessions.get_mut(&id) else {
|
||||
// Killed while we were spawning. Undo the pane instead of
|
||||
// leaving one behind with no record of it.
|
||||
let tmux = self.inner.tmux.clone();
|
||||
let _ = tmux_blocking(move || tmux.kill_session(id)).await;
|
||||
return Err(SessionError::NotFound(id));
|
||||
};
|
||||
rec.session = session.clone();
|
||||
rec.fifo_path = fifo_path;
|
||||
rec.bridge = Some(bridge);
|
||||
rec.reported_activity = None;
|
||||
rec.settled = false;
|
||||
rec.focused_since = None;
|
||||
rec.awaiting_suppressed = false;
|
||||
}
|
||||
let _ = self.inner.events.send(CatalogEvent::SessionUpdated {
|
||||
session: session.clone(),
|
||||
});
|
||||
Ok(session)
|
||||
}
|
||||
|
||||
/// Best-effort removal of a worktree created earlier in `create` when a
|
||||
/// later step fails. No-op when the request had no worktree.
|
||||
fn rollback_worktree(&self, base: &Option<PathBuf>, path: &Option<PathBuf>) {
|
||||
@@ -1540,7 +1657,6 @@ async fn mark_session_dead(inner: &Arc<Inner>, id: Uuid) {
|
||||
// The tree is settled (removed); a stale "in the way" would
|
||||
// describe something the session no longer holds.
|
||||
session.blocked_work = None;
|
||||
session.worktree = None;
|
||||
let mut sessions = inner.sessions.lock().await;
|
||||
if let Some(record) = sessions.get_mut(&id) {
|
||||
record.session.worktree = None;
|
||||
|
||||
@@ -94,6 +94,74 @@ async fn rejects_unknown_session() {
|
||||
assert!(format!("{err}").contains("404"));
|
||||
}
|
||||
|
||||
/// #166: a session the daemon knows but has no bridge for is not unknown, it
|
||||
/// is not running. `404 unknown_session` made "gone" and "resumable" look the
|
||||
/// same to a client that could have offered a resume.
|
||||
#[tokio::test]
|
||||
async fn dormant_session_is_not_attachable_rather_than_unknown() {
|
||||
let h = spawn_daemon_ws().await;
|
||||
let cwd = TempDir::new().unwrap();
|
||||
let session = h
|
||||
.manager
|
||||
.create(CreateSessionRequest {
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
command: "sleep 30".into(),
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// The host reboot: tmux is gone, the sidecar is not, and a fresh manager
|
||||
// registers the session dormant.
|
||||
h._fx.tmux.tmux.kill_server().unwrap();
|
||||
let dormant = SessionManager::new(
|
||||
std::sync::Arc::new(h._fx.tmux.tmux.clone()),
|
||||
maestro_daemon::sidecar::SidecarStore::new(h._fx.sidecar_dir()),
|
||||
maestro_daemon::worktree::WorktreeManager::new(None),
|
||||
h._fx.fifo_dir(),
|
||||
);
|
||||
dormant.recover().await.unwrap();
|
||||
let dormant_addr = serve_manager(dormant).await;
|
||||
|
||||
let err = connect_attach(dormant_addr, session.id).await.unwrap_err();
|
||||
let tokio_tungstenite::tungstenite::Error::Http(resp) = err else {
|
||||
panic!("want an HTTP rejection, got {err}");
|
||||
};
|
||||
assert_eq!(resp.status().as_u16(), 409);
|
||||
let body: maestro_protocol::ApiError =
|
||||
serde_json::from_slice(resp.body().as_deref().expect("error body")).unwrap();
|
||||
assert_eq!(body.error.code, "not_attachable");
|
||||
|
||||
// A session that really is unknown still reads as unknown.
|
||||
let err = connect_attach(dormant_addr, Uuid::new_v4())
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(format!("{err}").contains("404"));
|
||||
}
|
||||
|
||||
/// Serve `manager` on its own listener, for tests that need a second daemon
|
||||
/// over the same tmux/sidecar state.
|
||||
async fn serve_manager(manager: SessionManager) -> SocketAddr {
|
||||
let router = local_api::router(local_api::state_for(
|
||||
manager,
|
||||
"test-host",
|
||||
"/home/test",
|
||||
Vec::new(),
|
||||
std::sync::Arc::new(maestro_daemon::projects::ProjectScanner::disabled()),
|
||||
maestro_config::DEFAULT_LEADER,
|
||||
));
|
||||
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
|
||||
.await
|
||||
.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
tokio::spawn(async move {
|
||||
let _ = axum::serve(listener, router).await;
|
||||
});
|
||||
addr
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pty_round_trip_through_real_tmux() {
|
||||
let h = spawn_daemon_ws().await;
|
||||
|
||||
@@ -0,0 +1,359 @@
|
||||
//! End-to-end tests for `POST /api/sessions/:id/resume` (#166) and the
|
||||
//! manager call behind it, against real tmux.
|
||||
//!
|
||||
//! The point of resume is that the session comes back under the *same* uuid
|
||||
//! and sidecar — name, `created_at`, env and worktree ownership intact — with
|
||||
//! only the tmux session and the PTY bridge new.
|
||||
|
||||
mod common;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::net::{Ipv4Addr, SocketAddr};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use common::{wait_for_async, DaemonFixture};
|
||||
use maestro_daemon::{local_api, SessionError, SessionManager};
|
||||
use maestro_protocol::{CreateSessionRequest, Session, SessionStatus, SpawnPreset};
|
||||
use tempfile::TempDir;
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::sync::oneshot;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// A fresh manager over the same tmux server, sidecars and fifos — what the
|
||||
/// daemon builds after a restart.
|
||||
fn revive(fx: &DaemonFixture) -> SessionManager {
|
||||
SessionManager::new(
|
||||
Arc::new(fx.tmux.tmux.clone()),
|
||||
maestro_daemon::sidecar::SidecarStore::new(fx.sidecar_dir()),
|
||||
maestro_daemon::worktree::WorktreeManager::new(None),
|
||||
fx.fifo_dir(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Create a session, then simulate the host reboot that leaves it dormant:
|
||||
/// the tmux server dies, the sidecar stays, a new manager recovers.
|
||||
async fn dormant_session(
|
||||
fx: &DaemonFixture,
|
||||
cwd: &TempDir,
|
||||
command: &str,
|
||||
name: &str,
|
||||
) -> (SessionManager, Session) {
|
||||
let mut metadata = HashMap::new();
|
||||
metadata.insert(
|
||||
maestro_protocol::NAME_KEY.to_string(),
|
||||
serde_json::Value::String(name.into()),
|
||||
);
|
||||
let session = fx
|
||||
.manager
|
||||
.create(CreateSessionRequest {
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
command: command.into(),
|
||||
env: HashMap::from([("MAESTRO_TEST_ENV".to_string(), "kept".to_string())]),
|
||||
metadata,
|
||||
worktree: None,
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
fx.tmux.tmux.kill_server().expect("kill tmux server");
|
||||
let revived = revive(fx);
|
||||
revived.recover().await.expect("recover");
|
||||
(revived, session)
|
||||
}
|
||||
|
||||
fn marker_preset(command: &str, marker_dir: &TempDir) -> Vec<SpawnPreset> {
|
||||
vec![SpawnPreset {
|
||||
id: "agent".into(),
|
||||
label: "Agent".into(),
|
||||
command: command.into(),
|
||||
resume_command: Some(format!(
|
||||
"sh -c 'touch {}/resumed-{{agent_session}}; sleep 30'",
|
||||
marker_dir.path().display()
|
||||
)),
|
||||
}]
|
||||
}
|
||||
|
||||
/// The keystone: a dormant session comes back running under its own id, with
|
||||
/// its name, `created_at`, env and sidecar untouched.
|
||||
#[tokio::test]
|
||||
async fn resume_brings_a_dormant_session_back_under_the_same_id() {
|
||||
let fx = DaemonFixture::new();
|
||||
let cwd = TempDir::new().unwrap();
|
||||
let (manager, original) = dormant_session(&fx, &cwd, "sleep 30", "long-runner").await;
|
||||
assert_eq!(
|
||||
manager.status(original.id).await,
|
||||
Some(SessionStatus::Dormant)
|
||||
);
|
||||
|
||||
let resumed = manager.resume(original.id, &[]).await.expect("resume");
|
||||
|
||||
assert_eq!(resumed.id, original.id, "same uuid");
|
||||
assert_eq!(resumed.status, SessionStatus::Running);
|
||||
assert_eq!(resumed.created_at, original.created_at);
|
||||
assert_eq!(resumed.cwd, original.cwd);
|
||||
assert_eq!(resumed.command, original.command);
|
||||
assert_eq!(resumed.env, original.env);
|
||||
assert_eq!(
|
||||
resumed
|
||||
.metadata
|
||||
.get(maestro_protocol::NAME_KEY)
|
||||
.and_then(|v| v.as_str()),
|
||||
Some("long-runner")
|
||||
);
|
||||
|
||||
// tmux really holds it again, and the sidecar is the same file, updated.
|
||||
let live = fx.tmux.tmux.list_sessions().expect("list");
|
||||
assert_eq!(live, vec![original.id]);
|
||||
let stored = maestro_daemon::sidecar::SidecarStore::new(fx.sidecar_dir())
|
||||
.read(original.id)
|
||||
.expect("read sidecar")
|
||||
.expect("sidecar still there");
|
||||
assert_eq!(stored.status, SessionStatus::Running);
|
||||
assert_eq!(stored.created_at, original.created_at);
|
||||
|
||||
// And it is attachable again, which a dormant session never was.
|
||||
assert!(manager
|
||||
.subscribe_pty_with_replay(original.id)
|
||||
.await
|
||||
.is_some());
|
||||
}
|
||||
|
||||
/// The worktree the session owns stays the session's, so killing the resumed
|
||||
/// session still cleans the tree up.
|
||||
#[tokio::test]
|
||||
async fn resume_keeps_worktree_ownership() {
|
||||
let fx = DaemonFixture::new();
|
||||
let repo = TempDir::new().unwrap();
|
||||
common::init_repo(repo.path());
|
||||
|
||||
let session = fx
|
||||
.manager
|
||||
.create(CreateSessionRequest {
|
||||
cwd: repo.path().to_path_buf(),
|
||||
command: "sleep 30".into(),
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: Some(maestro_protocol::WorktreeSpec {
|
||||
base_repo: repo.path().to_path_buf(),
|
||||
branch: "feature".into(),
|
||||
create_branch: true,
|
||||
}),
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
let worktree = session.worktree.clone().expect("worktree");
|
||||
|
||||
fx.tmux.tmux.kill_server().expect("kill tmux server");
|
||||
let manager = revive(&fx);
|
||||
manager.recover().await.expect("recover");
|
||||
let resumed = manager.resume(session.id, &[]).await.expect("resume");
|
||||
|
||||
assert_eq!(resumed.worktree, Some(worktree.clone()));
|
||||
assert_eq!(
|
||||
resumed.cwd, worktree.path,
|
||||
"resumed inside its own worktree"
|
||||
);
|
||||
}
|
||||
|
||||
/// The command comes from the preset's `resume_command`, with the session's
|
||||
/// own agent conversation id (#161) substituted in.
|
||||
#[tokio::test]
|
||||
async fn resume_runs_the_presets_resume_command() {
|
||||
let fx = DaemonFixture::new();
|
||||
let cwd = TempDir::new().unwrap();
|
||||
let markers = TempDir::new().unwrap();
|
||||
let presets = marker_preset("sleep 30", &markers);
|
||||
|
||||
let (manager, session) = dormant_session(&fx, &cwd, "sleep 30", "agentic").await;
|
||||
// The agent reported its conversation id before the reboot, so it is on
|
||||
// the sidecar and the resume template can be filled in.
|
||||
fx.manager
|
||||
.report_activity(
|
||||
session.id,
|
||||
maestro_protocol::ActivityState::Idle,
|
||||
None,
|
||||
Some("conv-42".into()),
|
||||
)
|
||||
.await
|
||||
.ok();
|
||||
// Re-read the sidecar the report updated.
|
||||
let manager = {
|
||||
drop(manager);
|
||||
let m = revive(&fx);
|
||||
m.recover().await.expect("recover");
|
||||
m
|
||||
};
|
||||
|
||||
manager.resume(session.id, &presets).await.expect("resume");
|
||||
|
||||
let marker = markers.path().join("resumed-conv-42");
|
||||
wait_for_async(Duration::from_secs(5), || {
|
||||
let marker = marker.clone();
|
||||
async move { marker.exists() }
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
/// A session that is already running has nothing to bring back, and
|
||||
/// respawning would strand the pane that is already there.
|
||||
#[tokio::test]
|
||||
async fn resume_refuses_a_running_session() {
|
||||
let fx = DaemonFixture::new();
|
||||
let cwd = TempDir::new().unwrap();
|
||||
let session = fx
|
||||
.manager
|
||||
.create(CreateSessionRequest {
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
command: "sleep 30".into(),
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
|
||||
let err = fx
|
||||
.manager
|
||||
.resume(session.id, &[])
|
||||
.await
|
||||
.expect_err("already running");
|
||||
assert!(matches!(err, SessionError::AlreadyRunning(id) if id == session.id));
|
||||
assert_eq!(err.to_error_info().code, "already_running");
|
||||
}
|
||||
|
||||
/// Nothing can be resumed in a directory that no longer exists.
|
||||
#[tokio::test]
|
||||
async fn resume_refuses_when_the_cwd_is_gone() {
|
||||
let fx = DaemonFixture::new();
|
||||
let cwd = TempDir::new().unwrap();
|
||||
let (manager, session) = dormant_session(&fx, &cwd, "sleep 30", "homeless").await;
|
||||
|
||||
std::fs::remove_dir_all(cwd.path()).unwrap();
|
||||
|
||||
let err = manager
|
||||
.resume(session.id, &[])
|
||||
.await
|
||||
.expect_err("cwd is gone");
|
||||
assert_eq!(err.to_error_info().code, "cwd_missing");
|
||||
assert_eq!(
|
||||
manager.status(session.id).await,
|
||||
Some(SessionStatus::Dormant),
|
||||
"a refused resume leaves the session exactly as it was"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resume_of_an_unknown_session_is_not_found() {
|
||||
let fx = DaemonFixture::new();
|
||||
let err = fx
|
||||
.manager
|
||||
.resume(Uuid::new_v4(), &[])
|
||||
.await
|
||||
.expect_err("unknown");
|
||||
assert_eq!(err.to_error_info().code, "not_found");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HTTP surface
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct Harness {
|
||||
addr: SocketAddr,
|
||||
_shutdown: oneshot::Sender<()>,
|
||||
}
|
||||
|
||||
async fn serve(manager: SessionManager, presets: Vec<SpawnPreset>) -> Harness {
|
||||
let router = local_api::router(local_api::state_for(
|
||||
manager,
|
||||
"test-host",
|
||||
"/home/test",
|
||||
presets,
|
||||
Arc::new(maestro_daemon::projects::ProjectScanner::disabled()),
|
||||
maestro_config::DEFAULT_LEADER,
|
||||
));
|
||||
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
|
||||
.await
|
||||
.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let (tx, rx) = oneshot::channel::<()>();
|
||||
tokio::spawn(async move {
|
||||
let _ = axum::serve(listener, router)
|
||||
.with_graceful_shutdown(async move {
|
||||
let _ = rx.await;
|
||||
})
|
||||
.await;
|
||||
});
|
||||
Harness {
|
||||
addr,
|
||||
_shutdown: tx,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn post_resume_returns_the_revived_session() {
|
||||
let fx = DaemonFixture::new();
|
||||
let cwd = TempDir::new().unwrap();
|
||||
let (manager, session) = dormant_session(&fx, &cwd, "sleep 30", "web-resumed").await;
|
||||
let h = serve(manager, Vec::new()).await;
|
||||
|
||||
let resp = reqwest::Client::new()
|
||||
.post(format!(
|
||||
"http://{}{}",
|
||||
h.addr,
|
||||
maestro_protocol::routes::session_resume(session.id)
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), reqwest::StatusCode::OK);
|
||||
let body: Session = resp.json().await.unwrap();
|
||||
assert_eq!(body.id, session.id);
|
||||
assert_eq!(body.status, SessionStatus::Running);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn post_resume_on_a_running_session_is_a_conflict() {
|
||||
let fx = DaemonFixture::new();
|
||||
let cwd = TempDir::new().unwrap();
|
||||
let session = fx
|
||||
.manager
|
||||
.create(CreateSessionRequest {
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
command: "sleep 30".into(),
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
let h = serve(fx.manager.clone(), Vec::new()).await;
|
||||
|
||||
let resp = reqwest::Client::new()
|
||||
.post(format!(
|
||||
"http://{}{}",
|
||||
h.addr,
|
||||
maestro_protocol::routes::session_resume(session.id)
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), reqwest::StatusCode::CONFLICT);
|
||||
let err: maestro_protocol::ApiError = resp.json().await.unwrap();
|
||||
assert_eq!(err.error.code, "already_running");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn post_resume_on_an_unknown_session_is_404() {
|
||||
let fx = DaemonFixture::new();
|
||||
let h = serve(fx.manager.clone(), Vec::new()).await;
|
||||
let resp = reqwest::Client::new()
|
||||
.post(format!(
|
||||
"http://{}{}",
|
||||
h.addr,
|
||||
maestro_protocol::routes::session_resume(Uuid::new_v4())
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), reqwest::StatusCode::NOT_FOUND);
|
||||
}
|
||||
@@ -9,7 +9,6 @@ use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use common::{init_bare_repo, init_repo, push_all_to_origin, wait_for_async, DaemonFixture};
|
||||
use maestro_daemon::sidecar::SidecarStore;
|
||||
use maestro_daemon::SessionError;
|
||||
use maestro_protocol::CreateSessionRequest;
|
||||
use maestro_protocol::{CatalogEvent, SessionStatus, WorktreeSpec};
|
||||
@@ -1305,65 +1304,6 @@ async fn natural_exit_of_a_pristine_worktree_removes_it() {
|
||||
);
|
||||
}
|
||||
|
||||
/// #172: `mark_session_dead` clears the worktree off the in-memory record
|
||||
/// when a pristine tree settles, but the local `session` clone it writes to
|
||||
/// the sidecar and broadcasts must be cleared the same way — otherwise the
|
||||
/// sidecar keeps naming a tree the daemon just removed, and a recovery from
|
||||
/// that sidecar advertises a branch and path that no longer exist.
|
||||
#[tokio::test]
|
||||
async fn natural_exit_of_a_pristine_worktree_clears_it_from_the_sidecar() {
|
||||
let fx = DaemonFixture::new();
|
||||
let repo = TempDir::new().unwrap();
|
||||
init_repo(repo.path());
|
||||
let wt_path = repo.path().join("worktrees").join("feat").join("sidecar");
|
||||
|
||||
let s = fx
|
||||
.manager
|
||||
.create(CreateSessionRequest {
|
||||
cwd: repo.path().to_path_buf(),
|
||||
command: "sleep 30".into(),
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: Some(WorktreeSpec {
|
||||
base_repo: repo.path().to_path_buf(),
|
||||
branch: "feat/sidecar".into(),
|
||||
create_branch: true,
|
||||
}),
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
|
||||
// Push everything so the branch has no unpushed commits: fully pristine.
|
||||
let origin = TempDir::new().unwrap();
|
||||
init_bare_repo(origin.path());
|
||||
push_all_to_origin(repo.path(), origin.path());
|
||||
|
||||
fx.tmux.tmux.kill_session(s.id).expect("kill tmux directly");
|
||||
fx.manager.start_liveness_watcher(Duration::from_millis(50));
|
||||
wait_for_async(Duration::from_secs(3), || async {
|
||||
fx.manager.status(s.id).await == Some(SessionStatus::Dead)
|
||||
})
|
||||
.await;
|
||||
wait_for_async(Duration::from_secs(3), || async {
|
||||
fx.manager.is_settled(s.id).await == Some(true)
|
||||
})
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
!wt_path.exists(),
|
||||
"a pristine worktree leaves nothing behind"
|
||||
);
|
||||
|
||||
let sidecar = SidecarStore::new(fx.sidecar_dir())
|
||||
.read(s.id)
|
||||
.expect("sidecar read")
|
||||
.expect("sidecar exists");
|
||||
assert!(
|
||||
sidecar.worktree.is_none(),
|
||||
"the sidecar must not keep naming a worktree the daemon already removed"
|
||||
);
|
||||
}
|
||||
|
||||
fn run_git_in(dir: &std::path::Path, args: &[&str]) {
|
||||
let out = Command::new("git")
|
||||
.arg("-C")
|
||||
|
||||
@@ -536,6 +536,29 @@ pub struct SpawnPreset {
|
||||
pub resume_command: Option<String>,
|
||||
}
|
||||
|
||||
/// Placeholder a preset's `resume_command` may carry for the agent's own
|
||||
/// conversation id (#165), substituted from [`Session::agent_session_id`].
|
||||
pub const AGENT_SESSION_PLACEHOLDER: &str = "{agent_session}";
|
||||
|
||||
impl SpawnPreset {
|
||||
/// The command that resumes a session spawned from this preset, with
|
||||
/// [`AGENT_SESSION_PLACEHOLDER`] filled in from `agent_session`.
|
||||
///
|
||||
/// `None` when the preset has no `resume_command` — a plain shell has
|
||||
/// nothing to resume — and also when the template asks for an agent
|
||||
/// session id the session never reported: `claude --resume` with an empty
|
||||
/// id resumes nothing, so the caller is better off respawning the original
|
||||
/// command than running a broken one.
|
||||
pub fn resume_command_for(&self, agent_session: Option<&str>) -> Option<String> {
|
||||
let template = self.resume_command.as_deref()?;
|
||||
if !template.contains(AGENT_SESSION_PLACEHOLDER) {
|
||||
return Some(template.to_string());
|
||||
}
|
||||
let id = agent_session.filter(|s| !s.is_empty())?;
|
||||
Some(template.replace(AGENT_SESSION_PLACEHOLDER, id))
|
||||
}
|
||||
}
|
||||
|
||||
/// Build provenance of the running daemon: the commit it was compiled
|
||||
/// from and when that commit was authored.
|
||||
///
|
||||
@@ -785,6 +808,47 @@ pub enum ClientControl {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn preset(resume: Option<&str>) -> SpawnPreset {
|
||||
SpawnPreset {
|
||||
id: "claude".into(),
|
||||
label: "Claude".into(),
|
||||
command: "claude".into(),
|
||||
resume_command: resume.map(str::to_string),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resume_command_substitutes_the_agent_session() {
|
||||
let p = preset(Some("claude --resume {agent_session}"));
|
||||
assert_eq!(
|
||||
p.resume_command_for(Some("abc-123")).as_deref(),
|
||||
Some("claude --resume abc-123")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resume_command_without_a_placeholder_ignores_the_agent_session() {
|
||||
let p = preset(Some("opencode --continue"));
|
||||
assert_eq!(
|
||||
p.resume_command_for(None).as_deref(),
|
||||
Some("opencode --continue")
|
||||
);
|
||||
}
|
||||
|
||||
/// A template that wants an id the session never reported would run
|
||||
/// `claude --resume` against nothing; the caller respawns instead.
|
||||
#[test]
|
||||
fn resume_command_is_none_without_the_id_it_asks_for() {
|
||||
let p = preset(Some("claude --resume {agent_session}"));
|
||||
assert!(p.resume_command_for(None).is_none());
|
||||
assert!(p.resume_command_for(Some("")).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_preset_without_a_resume_command_has_none() {
|
||||
assert!(preset(None).resume_command_for(Some("abc")).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_compatibility_is_major_only() {
|
||||
assert!(is_compatible(PROTOCOL_VERSION));
|
||||
|
||||
@@ -66,6 +66,12 @@ pub fn session_focus(id: Uuid) -> String {
|
||||
format!("{SESSIONS}/{id}/focus")
|
||||
}
|
||||
|
||||
/// `POST /api/sessions/:id/resume` (#166): respawn a dormant session under its
|
||||
/// own id, keeping its sidecar, name, worktree and `created_at`.
|
||||
pub fn session_resume(id: Uuid) -> String {
|
||||
format!("{SESSIONS}/{id}/resume")
|
||||
}
|
||||
|
||||
/// `WS /api/attach/:id` — raw PTY bytes.
|
||||
pub fn attach(id: Uuid) -> String {
|
||||
format!("{API_PREFIX}/attach/{id}")
|
||||
@@ -79,6 +85,7 @@ pub mod patterns {
|
||||
pub const SESSION_INPUT: &str = "/api/sessions/:id/input";
|
||||
pub const SESSION_PROMPT: &str = "/api/sessions/:id/prompt";
|
||||
pub const SESSION_FOCUS: &str = "/api/sessions/:id/focus";
|
||||
pub const SESSION_RESUME: &str = "/api/sessions/:id/resume";
|
||||
pub const ATTACH: &str = "/api/attach/:id";
|
||||
}
|
||||
|
||||
@@ -202,6 +209,10 @@ mod tests {
|
||||
session_focus(id()),
|
||||
"/api/sessions/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50/focus"
|
||||
);
|
||||
assert_eq!(
|
||||
session_resume(id()),
|
||||
"/api/sessions/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50/resume"
|
||||
);
|
||||
assert_eq!(
|
||||
attach(id()),
|
||||
"/api/attach/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50"
|
||||
@@ -236,6 +247,10 @@ mod tests {
|
||||
patterns::SESSION_FOCUS.trim_end_matches(":id/focus"),
|
||||
session(id()).trim_end_matches(&id().to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
patterns::SESSION_RESUME.trim_end_matches(":id/resume"),
|
||||
session(id()).trim_end_matches(&id().to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
patterns::ATTACH.trim_end_matches(":id"),
|
||||
attach(id()).trim_end_matches(&id().to_string())
|
||||
|
||||
@@ -47,7 +47,6 @@ Web client (`--preview` and friends in `web/src/styles.css`):
|
||||
| The canvas | A `preview` chip, top right, and a dashed inset ring. |
|
||||
| A throwaway tab | Dashed border, italic label. |
|
||||
| The sidebar row being previewed | Filled with the preview hue, over whatever open state the row already had — but the row of the session already showing in the pane keeps its accent fill, since previewing it changes nothing on screen. |
|
||||
| The sidebar row of the session the operator picked | Keeps its accent fill for the whole preview. A preview never moves the accent; only opening another session does. |
|
||||
| A hovered sidebar row, and the keyboard cursor on a session | A lighter wash of the same hue, because both are what previews a row. |
|
||||
|
||||
Rows say it with fill, not with an edge — see the tier ladder in
|
||||
|
||||
+20
-3
@@ -79,9 +79,26 @@ to adopt while every session's sidecar is still on disk. Recovery makes a
|
||||
second pass over those sidecars and registers each as a `Dormant` session
|
||||
(#162) — id, name, cwd, command, worktree and `created_at` intact. A dormant
|
||||
session has no pane and no PTY bridge: it cannot be attached to, typed into or
|
||||
resized, and neither watcher pass touches it. It is a record of what was
|
||||
running, and the only thing left to do with one is dismiss it, which removes
|
||||
the sidecar so the next recovery does not bring it back.
|
||||
resized, and neither watcher pass touches it.
|
||||
|
||||
A dormant session is brought back with `POST /api/sessions/:id/resume` (#166),
|
||||
which respawns tmux under the **same** uuid and sidecar — `metadata.name`,
|
||||
`created_at`, `env` and worktree ownership all survive, and only the tmux
|
||||
session and the PTY bridge are new. The command is the matching spawn preset's
|
||||
`resume_command` with `{agent_session}` filled in from the session's own
|
||||
`agent_session_id`, so an agent comes back inside its old conversation; a
|
||||
session with no matching preset, no `resume_command`, or no reported
|
||||
conversation id is respawned on the command it was created with. `Session::command`
|
||||
always keeps that original command, so a second resume resolves the same preset.
|
||||
Resume is refused with `409` for a session that is already running
|
||||
(`already_running`) and for one whose `cwd` has disappeared (`cwd_missing`) —
|
||||
neither is something a respawn could fix. The other thing to do with a dormant
|
||||
session is dismiss it, which removes the sidecar so the next recovery does not
|
||||
bring it back.
|
||||
|
||||
Attaching to a session that has no bridge answers `409 not_attachable`, not
|
||||
`404 unknown_session`: the session exists, and a client that cannot tell "gone"
|
||||
from "not running" cannot offer the resume.
|
||||
|
||||
`SessionStatus` is a wire type, so from this build forward it deserializes
|
||||
leniently on both clients: a spelling the client does not know reads as `Dead`
|
||||
|
||||
@@ -34,14 +34,12 @@ describe("resolveFastForward (#67)", () => {
|
||||
const res = resolveFastForward(projects, presets, "amber-falcon");
|
||||
expect(res.project?.path).toBe("/repos/fresh");
|
||||
expect(res.command).toBe("aider");
|
||||
expect(res.handle).toBe("amber-falcon");
|
||||
expect(res.name).toBe("amber-falcon");
|
||||
|
||||
const req = buildFastForwardRequest(res)!;
|
||||
expect(req.cwd).toBe("/repos/fresh");
|
||||
expect(req.command).toBe("aider");
|
||||
// No name (#131): the daemon names the session after its first prompt,
|
||||
// and a name here would collide with the branch handle.
|
||||
expect(req.metadata).toBeUndefined();
|
||||
expect(req.metadata).toEqual({ name: "amber-falcon" });
|
||||
expect(req.worktree).toEqual({
|
||||
base_repo: "/repos/fresh",
|
||||
branch: "amber-falcon",
|
||||
@@ -90,9 +88,9 @@ describe("resolveFastForward (#67)", () => {
|
||||
expect(buildFastForwardRequest(res)).toBeNull();
|
||||
});
|
||||
|
||||
it("downgrades to a draft when the generated handle is missing", () => {
|
||||
it("downgrades to a draft when the generated name is missing", () => {
|
||||
const res = resolveFastForward([project({ last_command: "claude" })], presets);
|
||||
expect(res.handle).toBeUndefined();
|
||||
expect(res.name).toBeUndefined();
|
||||
expect(buildFastForwardRequest(res)).toBeNull();
|
||||
});
|
||||
|
||||
@@ -110,17 +108,6 @@ describe("resolveFastForward (#67)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("seeds the handle as the branch and leaves the name field empty (#131)", () => {
|
||||
const res = resolveFastForward(
|
||||
[project({ path: "/repos/p", base_repo: "/repos/p", last_command: "aider" })],
|
||||
presets,
|
||||
"amber-falcon",
|
||||
);
|
||||
const seed = fastForwardSeed(res);
|
||||
expect(seed.name).toBe("");
|
||||
expect(seed.worktreeBranch).toBe("amber-falcon");
|
||||
});
|
||||
|
||||
it("leaves the worktree default alone when not even a project resolved", () => {
|
||||
// Not `false`: the form defaults the toggle on and a false would stick,
|
||||
// so the fallback draft would disagree with a normally-opened draft and
|
||||
|
||||
+13
-19
@@ -10,21 +10,19 @@ import { sortProjects } from "./projectList.js";
|
||||
export interface FastForwardResolution {
|
||||
project?: Project;
|
||||
command?: string;
|
||||
/** A generated handle for the worktree branch (#131). Not a session name:
|
||||
* the fast path names no session. */
|
||||
handle?: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
/** The defaults: the currently focused session's project when it's still a
|
||||
* known project, else the most recent one (fresh-first, same ordering the
|
||||
* picker shows) — so `N` lands where the operator currently is, matching
|
||||
* what plain `n` seeds from `spawnSimilarProjectSeed`. Its last command
|
||||
* falls back to the first non-custom preset, and the branch handle is
|
||||
* freshly generated when the daemon supplied one. */
|
||||
* falls back to the first non-custom preset, and the name is freshly
|
||||
* generated when the daemon supplied one. */
|
||||
export function resolveFastForward(
|
||||
projects: Project[],
|
||||
presets: SpawnPreset[],
|
||||
handle?: string,
|
||||
name?: string,
|
||||
focusedCwd?: string,
|
||||
): FastForwardResolution {
|
||||
const project =
|
||||
@@ -32,45 +30,41 @@ export function resolveFastForward(
|
||||
const lastCommand = project?.last_command?.trim();
|
||||
const command =
|
||||
lastCommand || presets.find((p) => p.command !== "")?.command || undefined;
|
||||
return { project, command, handle: handle || undefined };
|
||||
return { project, command, name: name || undefined };
|
||||
}
|
||||
|
||||
/** The immediate spawn request — worktree on, new branch named after the
|
||||
* generated handle — or `null` when any default is missing.
|
||||
*
|
||||
* The request carries no name (#131): the daemon assigns a provisional one
|
||||
* and replaces it once the session sees its first prompt. A name here would
|
||||
* also collide with the branch handle's own session. */
|
||||
* generated session name — or `null` when any default is missing. */
|
||||
export function buildFastForwardRequest(
|
||||
res: FastForwardResolution,
|
||||
): CreateSessionRequest | null {
|
||||
const { project, command, handle } = res;
|
||||
if (!project || !command?.trim() || !handle) return null;
|
||||
const { project, command, name } = res;
|
||||
if (!project || !command?.trim() || !name) return null;
|
||||
return {
|
||||
cwd: project.path,
|
||||
command,
|
||||
metadata: { name },
|
||||
worktree: {
|
||||
base_repo: project.base_repo ?? project.path,
|
||||
branch: handle,
|
||||
branch: name,
|
||||
create_branch: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Seed for the fallback spawn form: whatever did resolve goes into the
|
||||
* form; the rest stays with its own defaults. The name field stays empty
|
||||
* (#131) — the handle seeds the branch, not the session name. */
|
||||
* form; the rest stays with its own defaults. */
|
||||
export function fastForwardSeed(res: FastForwardResolution) {
|
||||
return {
|
||||
cwd: res.project?.path ?? "",
|
||||
command: res.command ?? "",
|
||||
name: "",
|
||||
name: res.name ?? "",
|
||||
// Left undefined rather than false when nothing resolved: the form
|
||||
// defaults the toggle on, and `false` would stick, so the fallback form
|
||||
// would disagree with both a normally-opened form and the TUI's fast path
|
||||
// (#67 review).
|
||||
worktreeEnabled: res.project ? true : undefined,
|
||||
worktreeBranch:
|
||||
res.project && res.handle ? res.handle : undefined,
|
||||
res.project && res.name ? res.name : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
+4
-4
@@ -356,14 +356,14 @@ async function spawnPresets(): Promise<SpawnPreset[]> {
|
||||
}
|
||||
|
||||
async function requestFastForward(): Promise<void> {
|
||||
const [discovered, handle] = await Promise.all([
|
||||
const [discovered, name] = await Promise.all([
|
||||
fetchProjects().catch(() => [] as Project[]),
|
||||
fetchNames(1)
|
||||
.then((batch) => batch.names[0])
|
||||
.catch(() => undefined),
|
||||
]);
|
||||
const presets = await spawnPresets();
|
||||
const resolved = resolveFastForward(discovered, presets, handle, focusedProjectCwd());
|
||||
const resolved = resolveFastForward(discovered, presets, name, focusedProjectCwd());
|
||||
const req = buildFastForwardRequest(resolved);
|
||||
if (req) {
|
||||
try {
|
||||
@@ -390,7 +390,7 @@ async function requestFastForward(): Promise<void> {
|
||||
// one outside any repo spawns without a worktree, since there is no repo to cut
|
||||
// a branch from. Anything still unresolved opens the pre-filled form.
|
||||
async function requestSpawnInDirectory(path: string): Promise<void> {
|
||||
const [discovered, handle] = await Promise.all([
|
||||
const [discovered, name] = await Promise.all([
|
||||
fetchProjects().catch(() => [] as Project[]),
|
||||
fetchNames(1)
|
||||
.then((batch) => batch.names[0])
|
||||
@@ -399,7 +399,7 @@ async function requestSpawnInDirectory(path: string): Promise<void> {
|
||||
const presets = await spawnPresets();
|
||||
const known = discovered.find((p) => p.path === path);
|
||||
const project: Project = known ?? { path, name: path.split("/").filter(Boolean).pop() ?? path };
|
||||
const resolved = resolveFastForward([project], presets, handle, path);
|
||||
const resolved = resolveFastForward([project], presets, name, path);
|
||||
const req = buildFastForwardRequest(resolved);
|
||||
if (req) {
|
||||
const inRepo =
|
||||
|
||||
+3
-17
@@ -215,20 +215,6 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
||||
return fl?.sessionId ?? null;
|
||||
}
|
||||
|
||||
/** The session the operator actually picked — what `focusedLeafSession()`
|
||||
* would return with the preview undone. A preview moves the active tab and
|
||||
* its focus, but it is a view: the accent stays on the row the operator
|
||||
* chose until they choose another (docs/PREVIEW.md). */
|
||||
function realFocusedSession(): string | null {
|
||||
const p = preview;
|
||||
if (!p) return focusedLeafSession();
|
||||
const tab = layout.tabs.find((t) => t.id === p.prevActiveTabId) ?? activeTab(layout);
|
||||
const leafId =
|
||||
p.prevFocus && p.prevFocus.tabId === tab.id ? p.prevFocus.leafId : tab.focusedLeafId;
|
||||
const fl = leafId ? findLeaf(tab.root, leafId) : null;
|
||||
return fl?.sessionId ?? null;
|
||||
}
|
||||
|
||||
/** Update focus styling + pool focus + sidebar highlights without rebuilding
|
||||
* the DOM (cheap path for click-to-focus). */
|
||||
function syncFocus(): void {
|
||||
@@ -247,7 +233,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
||||
if (!preview) pool.setFocus(focusedLeafSession());
|
||||
sidebar.setHighlights({
|
||||
open: realOpenSessions(),
|
||||
focused: realFocusedSession(),
|
||||
focused: focusedLeafSession(),
|
||||
});
|
||||
// The row the preview came from wears the same colour, so the sidebar and
|
||||
// the pane read as one thing rather than two unrelated highlights.
|
||||
@@ -622,7 +608,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
||||
{ focus: !preview && !quietRender },
|
||||
);
|
||||
if (!preview) pool.setFocus(focused.sessionId ?? null);
|
||||
sidebar.setHighlights({ open: realOpenSessions(), focused: realFocusedSession() });
|
||||
sidebar.setHighlights({ open: realOpenSessions(), focused: focused.sessionId ?? null });
|
||||
sidebar.setPreview(preview?.sessionId ?? null);
|
||||
}
|
||||
|
||||
@@ -831,7 +817,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
||||
openSessions: realOpenSessions,
|
||||
beginSessionDrag,
|
||||
render,
|
||||
focusedSessionId: realFocusedSession,
|
||||
focusedSessionId: focusedLeafSession,
|
||||
previewSession,
|
||||
previewedSessionId: () => preview?.sessionId ?? null,
|
||||
};
|
||||
|
||||
+1
-33
@@ -47,20 +47,12 @@ function fakePool(): Spy {
|
||||
|
||||
/** Last value the controller pushed to `SidebarHandle.setPreview`. */
|
||||
const previewRow: { id: string | null } = { id: null };
|
||||
/** Last highlights the controller pushed to `SidebarHandle.setHighlights`. */
|
||||
const rowHighlights: { open: Set<string>; focused: string | null } = {
|
||||
open: new Set(),
|
||||
focused: null,
|
||||
};
|
||||
|
||||
function fakeSidebar(): SidebarHandle {
|
||||
return {
|
||||
el: document.createElement("aside"),
|
||||
setActive() {},
|
||||
setHighlights(h) {
|
||||
rowHighlights.open = h.open;
|
||||
rowHighlights.focused = h.focused;
|
||||
},
|
||||
setHighlights() {},
|
||||
setPreview(id: string | null) {
|
||||
previewRow.id = id;
|
||||
},
|
||||
@@ -103,8 +95,6 @@ describe("sidebar preview", () => {
|
||||
stubFetch();
|
||||
document.body.replaceChildren();
|
||||
previewRow.id = null;
|
||||
rowHighlights.open = new Set();
|
||||
rowHighlights.focused = null;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -144,28 +134,6 @@ describe("sidebar preview", () => {
|
||||
expect(mv.el.querySelectorAll(".mv-pane.is-preview")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("leaves the accent on the session the pane actually holds", () => {
|
||||
const layout = twoTabs();
|
||||
const spy = fakePool();
|
||||
const mv = makeMv(layout, spy.pool);
|
||||
expect(rowHighlights.focused).toBe("s2");
|
||||
|
||||
// Walking past other rows never moves the accent off what the operator
|
||||
// picked: only a real open does (docs/PREVIEW.md).
|
||||
mv.previewSession("s1");
|
||||
expect(rowHighlights.focused).toBe("s2");
|
||||
expect(mv.focusedSessionId()).toBe("s2");
|
||||
|
||||
mv.previewSession("s9");
|
||||
expect(rowHighlights.focused).toBe("s2");
|
||||
|
||||
mv.previewSession(null);
|
||||
expect(rowHighlights.focused).toBe("s2");
|
||||
|
||||
mv.openSession("s1");
|
||||
expect(rowHighlights.focused).toBe("s1");
|
||||
});
|
||||
|
||||
it("shows a session no pane holds in a throwaway tab that leaves no trace", () => {
|
||||
const layout = twoTabs();
|
||||
const before = serializeLayout(layout);
|
||||
|
||||
Reference in New Issue
Block a user