--force overrides the kill refusal and nothing else (#81). The guarded worktree is recorded in the retained store with reason force-killed — never removed — and the outcome names what was kept so a client can say where the work went. A tree whose guard is clear never needed the override; force removes it as usual. The store write happens before teardown, so a failed retention leaves the session running and every artifact intact. DELETE /api/sessions/:id takes ?force=true and answers 200 with KillOutcome; the unforced kill keeps its 204.
438 lines
16 KiB
Rust
438 lines
16 KiB
Rust
//! Daemon-side HTTP API for clients (NAP-243).
|
|
//!
|
|
//! These routes live on the daemon's `bind_addr` (default `127.0.0.1:7070`).
|
|
//! They're the contract `maestro-web`, the TUI, and the CLI call against.
|
|
//!
|
|
//! ## Trust model
|
|
//!
|
|
//! Whoever can reach `bind_addr` can use these routes. No JWT, no cookie. The
|
|
//! default `127.0.0.1` bind makes this same-trust as tmux (one-user-wide).
|
|
//! Operators who bind a LAN/VPN address opt into one-segment-wide trust — the
|
|
//! perimeter is theirs to choose, Maestro does not police it.
|
|
|
|
use std::sync::Arc;
|
|
|
|
use axum::extract::{Path, Query, State};
|
|
use axum::http::StatusCode;
|
|
use axum::response::{IntoResponse, Response};
|
|
use axum::routing::{any, delete, get};
|
|
use axum::{Json, Router};
|
|
use maestro_config::SpawnPreset;
|
|
use maestro_protocol::{
|
|
routes, ApiError, CatalogEvent, CreateSessionRequest, DaemonInfo, GitRoot, NameBatch, Session,
|
|
PROTOCOL_VERSION,
|
|
};
|
|
use serde::Deserialize;
|
|
use uuid::Uuid;
|
|
|
|
use crate::session::SessionManager;
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct LocalApiState {
|
|
pub manager: SessionManager,
|
|
pub hostname: Arc<String>,
|
|
/// Home directory of the OS user this daemon runs as, advertised so clients
|
|
/// can prefill a spawn form's working directory. Empty if unresolved.
|
|
pub home_dir: Arc<String>,
|
|
/// Resolved spawn presets (#22/#26) advertised in `GET /api/info`.
|
|
pub presets: Arc<Vec<SpawnPreset>>,
|
|
/// Project discovery (#54) backing `GET /api/projects`.
|
|
pub projects: Arc<crate::projects::ProjectScanner>,
|
|
/// Retained worktrees (#78) backing `GET /api/worktrees` and their
|
|
/// reclaim endpoint (#80).
|
|
pub retained: Arc<crate::retained_store::RetainedStore>,
|
|
}
|
|
|
|
pub fn router(state: LocalApiState) -> Router {
|
|
Router::new()
|
|
.route(routes::SESSIONS, get(list_sessions).post(create_session))
|
|
.route(routes::patterns::SESSION, delete(kill_session))
|
|
.route(routes::DIRS, get(list_dir))
|
|
.route(routes::RECENT_DIRS, get(recent_dirs))
|
|
.route(routes::PROJECTS, get(projects))
|
|
.route(routes::GITROOT, get(git_root))
|
|
.route(routes::BRANCHES, get(crate::branches::handler))
|
|
.route(routes::NAMES, get(names))
|
|
.route(routes::WORKTREES, get(list_worktrees))
|
|
.route(routes::patterns::WORKTREE, delete(reclaim_worktree))
|
|
.route(routes::INFO, get(daemon_info))
|
|
.route(routes::EVENTS, any(events_ws))
|
|
.route(routes::patterns::ATTACH, any(attach_ws))
|
|
.with_state(state)
|
|
}
|
|
|
|
async fn list_sessions(State(s): State<LocalApiState>) -> Json<Vec<Session>> {
|
|
Json(s.manager.snapshot().await)
|
|
}
|
|
|
|
/// Query string for `GET /api/dirs`. `path` is the directory to list (the
|
|
/// parent of what the user is typing); empty/`~`/relative are resolved against
|
|
/// the daemon's home dir.
|
|
#[derive(Debug, Deserialize)]
|
|
struct DirQuery {
|
|
#[serde(default)]
|
|
path: String,
|
|
}
|
|
|
|
/// List one directory's immediate subdirectories, for the spawn form's
|
|
/// working-directory autocomplete (NAP-329).
|
|
async fn list_dir(State(s): State<LocalApiState>, Query(q): Query<DirQuery>) -> Response {
|
|
match crate::dirlist::list_dir(&s.home_dir, &q.path) {
|
|
Ok(listing) => Json(listing).into_response(),
|
|
Err(info) => error_response(dir_error_status(&info.code), &info.code, &info.message),
|
|
}
|
|
}
|
|
|
|
/// HTTP status for a local [`crate::dirlist`] error code.
|
|
fn dir_error_status(code: &str) -> StatusCode {
|
|
match code {
|
|
"not_found" => StatusCode::NOT_FOUND,
|
|
"permission_denied" => StatusCode::FORBIDDEN,
|
|
"not_a_directory" => StatusCode::BAD_REQUEST,
|
|
_ => StatusCode::BAD_GATEWAY,
|
|
}
|
|
}
|
|
|
|
/// HTTP status for a [`crate::session::SessionError`] code. Everything used to
|
|
/// collapse to 502, which reports a client mistake as a daemon failure.
|
|
fn session_error_status(code: &str) -> StatusCode {
|
|
match code {
|
|
"not_found" => StatusCode::NOT_FOUND,
|
|
"invalid_env" => StatusCode::BAD_REQUEST,
|
|
// A guard refusal is the daemon doing its job, not a failure (#79).
|
|
"kill_guarded" => StatusCode::CONFLICT,
|
|
// The retention record failed before anything was torn down (#81).
|
|
"retain_failed" => StatusCode::INTERNAL_SERVER_ERROR,
|
|
_ => StatusCode::BAD_GATEWAY,
|
|
}
|
|
}
|
|
|
|
/// Recent working dirs for the spawn form's recents picker (#26).
|
|
async fn recent_dirs(State(s): State<LocalApiState>) -> Response {
|
|
Json(crate::spawn_history::to_wire(s.manager.recent_dirs())).into_response()
|
|
}
|
|
|
|
/// Discovered git repositories and worktrees, newest activity first (#54).
|
|
/// `last_activity` is the later of the repo's git HEAD mtime and the newest
|
|
/// spawn-history use of any directory inside it.
|
|
async fn projects(State(s): State<LocalApiState>) -> Response {
|
|
let repos = s.projects.repos().await;
|
|
let history = s.manager.all_recent_dirs();
|
|
Json(crate::projects::to_projects(&repos, &history)).into_response()
|
|
}
|
|
|
|
/// Probe the enclosing git repo of `path`, to infer the worktree `base_repo`
|
|
/// for the spawn form (#26). `path` is resolved against the daemon's home dir
|
|
/// (empty/`~`/relative), like [`list_dir`].
|
|
async fn git_root(State(s): State<LocalApiState>, Query(q): Query<DirQuery>) -> Response {
|
|
let dir = crate::dirlist::resolve(&s.home_dir, &q.path);
|
|
Json(GitRoot {
|
|
root: crate::gitroot::find_root(&dir),
|
|
})
|
|
.into_response()
|
|
}
|
|
|
|
/// Query string for `GET /api/names`. `count` defaults to 1 and is capped at
|
|
/// [`maestro_names::MAX_BATCH`] (#53).
|
|
#[derive(Debug, Deserialize)]
|
|
struct NamesQuery {
|
|
count: Option<usize>,
|
|
}
|
|
|
|
/// Fresh session handles for the spawn form's instant reroll (#53). The web
|
|
/// client fetches one batch per draft pane and rerolls locally from it.
|
|
async fn names(Query(q): Query<NamesQuery>) -> Json<NameBatch> {
|
|
let count = q.count.unwrap_or(1).clamp(1, maestro_names::MAX_BATCH);
|
|
Json(NameBatch {
|
|
names: maestro_names::generate_batch(count),
|
|
})
|
|
}
|
|
|
|
async fn daemon_info(State(s): State<LocalApiState>) -> Json<DaemonInfo> {
|
|
Json(DaemonInfo {
|
|
hostname: (*s.hostname).clone(),
|
|
version: PROTOCOL_VERSION.into(),
|
|
home_dir: (*s.home_dir).clone(),
|
|
presets: (*s.presets).clone(),
|
|
})
|
|
}
|
|
|
|
async fn create_session(
|
|
State(s): State<LocalApiState>,
|
|
Json(body): Json<CreateSessionRequest>,
|
|
) -> Response {
|
|
match s.manager.create(body).await {
|
|
Ok(session) => (StatusCode::CREATED, 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(),
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// `kill` already reports a missing session as `SessionError::NotFound`, which
|
|
/// maps to 404. Checking first would only add a window in which the liveness
|
|
/// watcher reaps the session between the two awaits, turning a 404 into a 502.
|
|
///
|
|
/// The full error info is passed through rather than rebuilt from
|
|
/// code + message: a `kill_guarded` refusal (#79) carries the unsaved-work
|
|
/// breakdown clients render as "3 uncommitted files, 2 unpushed commits".
|
|
///
|
|
/// With `force=true` (#81) the refusal is overridden and the worktree
|
|
/// retained; the answer is `200` with a [`KillOutcome`] naming what was kept.
|
|
/// Without it the historical `204` stands.
|
|
#[derive(Debug, Deserialize)]
|
|
struct KillQuery {
|
|
#[serde(default)]
|
|
force: bool,
|
|
}
|
|
|
|
async fn kill_session(
|
|
State(s): State<LocalApiState>,
|
|
Path(id): Path<Uuid>,
|
|
Query(q): Query<KillQuery>,
|
|
) -> Response {
|
|
match s.manager.kill(id, q.force).await {
|
|
Ok(outcome) => {
|
|
if q.force {
|
|
(StatusCode::OK, Json(outcome)).into_response()
|
|
} else {
|
|
StatusCode::NO_CONTENT.into_response()
|
|
}
|
|
}
|
|
Err(e) => {
|
|
let info = e.to_error_info();
|
|
let status = session_error_status(&info.code);
|
|
(status, Json(ApiError { error: info })).into_response()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// `GET /api/worktrees` (#80): every retained worktree, with the in-the-way
|
|
/// breakdown recomputed at read time — never stored, so a tree whose work has
|
|
/// since been committed or pushed says so. Entries whose directory somebody
|
|
/// removed by hand are pruned on the way through.
|
|
async fn list_worktrees(State(s): State<LocalApiState>) -> Response {
|
|
let retained = s.retained.clone();
|
|
let listed = tokio::task::spawn_blocking(move || {
|
|
retained.prune_missing()?;
|
|
retained
|
|
.load_all()?
|
|
.iter()
|
|
.map(|entry| {
|
|
// A tree git cannot read (permissions, corrupt checkout)
|
|
// still gets listed; it just reports nothing in the way.
|
|
let in_the_way = crate::worktree_probe::in_the_way(&entry.path).unwrap_or_default();
|
|
Ok(maestro_protocol::RetainedWorktree {
|
|
id: entry.id,
|
|
path: entry.path.clone(),
|
|
base_repo: entry.base_repo.clone(),
|
|
branch: entry.branch.clone(),
|
|
reason: entry.reason,
|
|
retained_at: entry.retained_at,
|
|
in_the_way,
|
|
})
|
|
})
|
|
.collect::<Result<Vec<_>, crate::retained_store::RetainedStoreError>>()
|
|
})
|
|
.await;
|
|
match listed {
|
|
Ok(Ok(worktrees)) => Json(worktrees).into_response(),
|
|
Ok(Err(e)) => error_response(
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
"store_failed",
|
|
&e.to_string(),
|
|
),
|
|
Err(e) => error_response(
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
"join_failed",
|
|
&e.to_string(),
|
|
),
|
|
}
|
|
}
|
|
|
|
/// `DELETE /api/worktrees/:id` (#80): reclaim one retained worktree. Removes
|
|
/// the directory and drops the store entry, without consulting the guard —
|
|
/// reclaim is the deliberate destructive act, chosen while looking at what is
|
|
/// in the way. The branch itself is never deleted; commits on it survive.
|
|
async fn reclaim_worktree(State(s): State<LocalApiState>, Path(id): Path<Uuid>) -> Response {
|
|
let found = {
|
|
let retained = s.retained.clone();
|
|
tokio::task::spawn_blocking(move || -> Result<
|
|
Option<crate::retained_store::RetainedWorktreeRecord>,
|
|
crate::retained_store::RetainedStoreError,
|
|
> {
|
|
Ok(retained.load_all()?.into_iter().find(|e| e.id == id))
|
|
})
|
|
.await
|
|
};
|
|
let entry = match found {
|
|
Ok(Ok(Some(entry))) => entry,
|
|
Ok(Ok(None)) => {
|
|
return error_response(
|
|
StatusCode::NOT_FOUND,
|
|
"not_found",
|
|
"no such retained worktree",
|
|
)
|
|
}
|
|
Ok(Err(e)) => {
|
|
return error_response(
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
"store_failed",
|
|
&e.to_string(),
|
|
)
|
|
}
|
|
Err(e) => {
|
|
return error_response(
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
"join_failed",
|
|
&e.to_string(),
|
|
)
|
|
}
|
|
};
|
|
|
|
let outcome = {
|
|
let retained = s.retained.clone();
|
|
tokio::task::spawn_blocking(move || -> Result<(), String> {
|
|
crate::worktree_probe::reclaim(&entry.base_repo, &entry.path)
|
|
.map_err(|e| e.to_string())?;
|
|
retained.remove(id).map_err(|e| e.to_string())?;
|
|
Ok(())
|
|
})
|
|
.await
|
|
};
|
|
match outcome {
|
|
Ok(Ok(())) => StatusCode::NO_CONTENT.into_response(),
|
|
// The directory could not be removed: leave the entry so the
|
|
// operator still sees the tree and can retry.
|
|
Ok(Err(e)) => error_response(StatusCode::BAD_GATEWAY, "reclaim_failed", &e.to_string()),
|
|
Err(e) => error_response(
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
"join_failed",
|
|
&e.to_string(),
|
|
),
|
|
}
|
|
}
|
|
|
|
fn error_response(status: StatusCode, code: &str, message: &str) -> Response {
|
|
(status, Json(ApiError::new(code, message))).into_response()
|
|
}
|
|
|
|
/// Convenience constructor — pulls the bits `LocalApiState` needs from
|
|
/// `DaemonConfig` so callers don't have to assemble it by hand. The project
|
|
/// scanner starts with its cache cold; wire
|
|
/// [`ProjectScanner::start_background_refresh`](crate::projects::ProjectScanner::start_background_refresh)
|
|
/// on the returned handle's scanner to keep it eventually fresh.
|
|
pub fn state_for(
|
|
manager: SessionManager,
|
|
hostname: impl Into<String>,
|
|
home_dir: impl Into<String>,
|
|
presets: Vec<SpawnPreset>,
|
|
projects: Arc<crate::projects::ProjectScanner>,
|
|
retained: Arc<crate::retained_store::RetainedStore>,
|
|
) -> LocalApiState {
|
|
LocalApiState {
|
|
manager,
|
|
hostname: Arc::new(hostname.into()),
|
|
home_dir: Arc::new(home_dir.into()),
|
|
presets: Arc::new(presets),
|
|
projects,
|
|
retained,
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// WS /api/attach/:id — raw PTY bytes for one session
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[allow(clippy::result_large_err)]
|
|
/// Subscribing *before* the upgrade makes the 404 and the subscription one
|
|
/// atomic step. Checking existence and then subscribing inside the callback
|
|
/// left a window in which the liveness watcher could reap the session, so a
|
|
/// client that passed the check still got a socket that immediately closed.
|
|
async fn attach_ws(
|
|
Path(id): Path<Uuid>,
|
|
State(s): State<LocalApiState>,
|
|
ws: axum::extract::WebSocketUpgrade,
|
|
) -> Result<Response, Response> {
|
|
let Some((replay, rx)) = s.manager.subscribe_pty_with_replay(id).await else {
|
|
return Err(error_response(
|
|
StatusCode::NOT_FOUND,
|
|
"unknown_session",
|
|
"no such session on this daemon",
|
|
));
|
|
};
|
|
let manager = s.manager.clone();
|
|
Ok(ws.on_upgrade(move |socket| async move {
|
|
let _ = crate::attach::serve(socket, manager, id, replay, rx).await;
|
|
}))
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// WS /api/events — live catalog deltas for browser/TUI subscribers (NAP-248)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async fn events_ws(
|
|
State(s): State<LocalApiState>,
|
|
ws: axum::extract::WebSocketUpgrade,
|
|
) -> Response {
|
|
ws.on_upgrade(move |socket| async move {
|
|
let _ = pump_events(socket, s).await;
|
|
})
|
|
}
|
|
|
|
async fn pump_events(
|
|
socket: axum::extract::ws::WebSocket,
|
|
s: LocalApiState,
|
|
) -> Result<(), axum::Error> {
|
|
use axum::extract::ws::Message;
|
|
use futures_util::{SinkExt, StreamExt};
|
|
use tokio::sync::broadcast::error::RecvError;
|
|
|
|
let (mut sink, mut stream) = socket.split();
|
|
|
|
// Subscribe BEFORE building the snapshot so deltas applied while we're
|
|
// marshaling can't slip between the two and disappear. The UI is
|
|
// idempotent for repeated *Added events, so any overlap is harmless.
|
|
let mut rx = s.manager.events();
|
|
|
|
let snapshot = CatalogEvent::Snapshot {
|
|
sessions: s.manager.snapshot().await,
|
|
};
|
|
let text = serde_json::to_string(&snapshot).expect("snapshot encode");
|
|
if sink.send(Message::Text(text)).await.is_err() {
|
|
return Ok(());
|
|
}
|
|
|
|
loop {
|
|
tokio::select! {
|
|
ev = rx.recv() => {
|
|
let event = match ev {
|
|
Ok(e) => e,
|
|
Err(RecvError::Lagged(n)) => CatalogEvent::Lagged { skipped: n },
|
|
Err(RecvError::Closed) => break,
|
|
};
|
|
let text = serde_json::to_string(&event).expect("event encode");
|
|
if sink.send(Message::Text(text)).await.is_err() {
|
|
break;
|
|
}
|
|
}
|
|
frame = stream.next() => {
|
|
// Server-push only. Anything from the client either closes or
|
|
// is ignored.
|
|
match frame {
|
|
None => break,
|
|
Some(Err(_)) => break,
|
|
Some(Ok(Message::Close(_))) => break,
|
|
Some(Ok(_)) => {}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|