diff --git a/crates/maestro-daemon/src/local_api.rs b/crates/maestro-daemon/src/local_api.rs index 6ae1c3a..06c3100 100644 --- a/crates/maestro-daemon/src/local_api.rs +++ b/crates/maestro-daemon/src/local_api.rs @@ -15,12 +15,12 @@ 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::routing::{any, delete, get, post}; use axum::{Json, Router}; use maestro_config::SpawnPreset; use maestro_protocol::{ - routes, ApiError, CatalogEvent, CreateSessionRequest, DaemonInfo, GitRoot, NameBatch, Session, - PROTOCOL_VERSION, + routes, ApiError, CatalogEvent, CreateSessionRequest, DaemonInfo, GitRoot, NameBatch, + ReportActivityRequest, Session, PROTOCOL_VERSION, }; use serde::Deserialize; use uuid::Uuid; @@ -46,6 +46,7 @@ pub struct LocalApiState { pub fn router(state: LocalApiState) -> Router { Router::new() .route(routes::SESSIONS, get(list_sessions).post(create_session)) + .route(routes::patterns::SESSION_ACTIVITY, post(report_activity)) .route(routes::patterns::SESSION, delete(kill_session)) .route(routes::DIRS, get(list_dir)) .route(routes::RECENT_DIRS, get(recent_dirs)) @@ -318,6 +319,29 @@ async fn reclaim_worktree(State(s): State, Path(id): Path) } } +/// `POST /api/sessions/:id/activity` (#105): an agent reports its own state. +/// The report holds above the daemon's inference for a TTL, then inference +/// resumes — an agent that dies mid-run cannot pin its session at `Error` +/// forever. Unknown session is 404; an unknown state value is rejected by the +/// JSON decoder (422) rather than coerced. Answers `204 No Content`. +async fn report_activity( + State(s): State, + Path(id): Path, + Json(body): Json, +) -> Response { + match s.manager.report_activity(id, body.state, body.detail).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 { (status, Json(ApiError::new(code, message))).into_response() } diff --git a/crates/maestro-daemon/tests/local_api.rs b/crates/maestro-daemon/tests/local_api.rs index c5d1ab7..88a712b 100644 --- a/crates/maestro-daemon/tests/local_api.rs +++ b/crates/maestro-daemon/tests/local_api.rs @@ -346,6 +346,109 @@ async fn delete_owned_session_kills_it() { assert!(!h.manager.has(session.id).await); } +/// Spawn a session through the manager and return its id. +async fn spawn_via_manager(h: &Harness) -> Uuid { + h.manager + .create(CreateSessionRequest { + cwd: "/tmp".into(), + command: "sleep 60".into(), + env: HashMap::new(), + metadata: HashMap::new(), + worktree: None, + }) + .await + .unwrap() + .id +} + +/// The agent activity endpoint on the wire (#105): a report lands in the +/// session snapshot with its detail. +#[tokio::test] +async fn post_activity_reports_error_with_detail() { + let h = spawn("naps-laptop").await; + let id = spawn_via_manager(&h).await; + + let resp = reqwest::Client::new() + .post(url(h.addr, &format!("/api/sessions/{id}/activity"))) + .json(&serde_json::json!({ + "state": "Error", + "detail": "cargo build failed" + })) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), reqwest::StatusCode::NO_CONTENT); + + let sessions: Vec = reqwest::get(url(h.addr, "/api/sessions")) + .await + .unwrap() + .json() + .await + .unwrap(); + let s = sessions.iter().find(|s| s.id == id).unwrap(); + assert_eq!(s.activity, maestro_protocol::ActivityState::Error); + assert_eq!(s.activity_detail.as_deref(), Some("cargo build failed")); +} + +#[tokio::test] +async fn post_activity_to_unknown_session_returns_404() { + let h = spawn("naps-laptop").await; + let resp = reqwest::Client::new() + .post(url( + h.addr, + &format!("/api/sessions/{}/activity", Uuid::new_v4()), + )) + .json(&serde_json::json!({ "state": "Idle" })) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), reqwest::StatusCode::NOT_FOUND); +} + +/// Unknown state values are rejected rather than coerced (#105). +#[tokio::test] +async fn post_activity_rejects_an_unknown_state_value() { + let h = spawn("naps-laptop").await; + let id = spawn_via_manager(&h).await; + + let resp = reqwest::Client::new() + .post(url(h.addr, &format!("/api/sessions/{id}/activity"))) + .json(&serde_json::json!({ "state": "Teleported" })) + .send() + .await + .unwrap(); + assert!(resp.status().is_client_error(), "got {}", resp.status()); +} + +#[tokio::test] +async fn post_activity_caps_an_overlong_detail_server_side() { + let h = spawn("naps-laptop").await; + let id = spawn_via_manager(&h).await; + + let resp = reqwest::Client::new() + .post(url(h.addr, &format!("/api/sessions/{id}/activity"))) + .json(&serde_json::json!({ + "state": "Error", + "detail": "x".repeat(10_000) + })) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), reqwest::StatusCode::NO_CONTENT); + + let sessions: Vec = reqwest::get(url(h.addr, "/api/sessions")) + .await + .unwrap() + .json() + .await + .unwrap(); + let s = sessions.iter().find(|s| s.id == id).unwrap(); + assert_eq!( + s.activity_detail.as_ref().map(|d| d.chars().count()), + Some(maestro_daemon::MAX_REPORT_DETAIL_CHARS) + ); +} + /// The kill guard on the wire (#79): `DELETE /api/sessions/:id` answers 409 /// with the structured breakdown, and the session keeps running. #[tokio::test]