Adds `SessionView { Terminal, Structured }` to maestro-protocol, a
defaulted `view` field on `Session` and `CreateSessionRequest`, and
sidecar round-trip tests for both a legacy payload with no `view` key
and a `Structured` session. Nothing reads the field yet; every other
struct literal across the workspace picks up `Default::default()`
(Terminal) to keep compiling.
Gitea #133.
166 lines
4.7 KiB
Rust
166 lines
4.7 KiB
Rust
//! Agent-reported activity (#105): `POST /api/sessions/:id/activity`
|
|
//! semantics at the SessionManager level — precedence over the daemon's own
|
|
//! inference, detail handling, and lapse back to inference at the TTL.
|
|
|
|
mod common;
|
|
|
|
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
|
|
use common::TmuxFixture;
|
|
use maestro_daemon::sidecar::SidecarStore;
|
|
use maestro_daemon::worktree::WorktreeManager;
|
|
use maestro_daemon::SessionManager;
|
|
use maestro_protocol::{ActivityState, CreateSessionRequest};
|
|
use tempfile::TempDir;
|
|
use tokio::time::sleep;
|
|
use uuid::Uuid;
|
|
|
|
struct Fixture {
|
|
manager: SessionManager,
|
|
_tmux: TmuxFixture,
|
|
_state_dir: TempDir,
|
|
}
|
|
|
|
async fn fixture(ttl: Duration) -> Fixture {
|
|
let tmux = TmuxFixture::new();
|
|
let state_dir = TempDir::new().unwrap();
|
|
let manager = SessionManager::new(
|
|
Arc::new(tmux.tmux.clone()),
|
|
SidecarStore::new(state_dir.path().join("sessions")),
|
|
WorktreeManager::new(None),
|
|
state_dir.path().join("fifos"),
|
|
)
|
|
.with_activity_report_ttl(ttl);
|
|
Fixture {
|
|
manager,
|
|
_tmux: tmux,
|
|
_state_dir: state_dir,
|
|
}
|
|
}
|
|
|
|
async fn spawn_sleeping(manager: &SessionManager) -> Uuid {
|
|
manager
|
|
.create(CreateSessionRequest {
|
|
view: Default::default(),
|
|
cwd: "/tmp".into(),
|
|
command: "sleep 60".into(),
|
|
env: Default::default(),
|
|
metadata: Default::default(),
|
|
worktree: None,
|
|
})
|
|
.await
|
|
.unwrap()
|
|
.id
|
|
}
|
|
|
|
async fn observed(manager: &SessionManager, id: Uuid) -> maestro_protocol::Session {
|
|
manager
|
|
.snapshot()
|
|
.await
|
|
.into_iter()
|
|
.find(|s| s.id == id)
|
|
.expect("session still tracked")
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn report_on_unknown_session_is_not_found() {
|
|
let fx = fixture(Duration::from_secs(300)).await;
|
|
let err = fx
|
|
.manager
|
|
.report_activity(Uuid::new_v4(), ActivityState::Idle, None)
|
|
.await
|
|
.unwrap_err();
|
|
assert!(matches!(err, maestro_daemon::SessionError::NotFound(_)));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn report_sets_error_state_and_detail() {
|
|
let fx = fixture(Duration::from_secs(300)).await;
|
|
let id = spawn_sleeping(&fx.manager).await;
|
|
|
|
fx.manager
|
|
.report_activity(
|
|
id,
|
|
ActivityState::Error,
|
|
Some("cargo build failed: link error".into()),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
let s = observed(&fx.manager, id).await;
|
|
assert_eq!(s.activity, ActivityState::Error);
|
|
assert_eq!(
|
|
s.activity_detail.as_deref(),
|
|
Some("cargo build failed: link error")
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn replacing_a_report_replaces_both_state_and_detail() {
|
|
let fx = fixture(Duration::from_secs(300)).await;
|
|
let id = spawn_sleeping(&fx.manager).await;
|
|
|
|
fx.manager
|
|
.report_activity(id, ActivityState::Error, Some("first failure".into()))
|
|
.await
|
|
.unwrap();
|
|
fx.manager
|
|
.report_activity(
|
|
id,
|
|
ActivityState::AwaitingInput,
|
|
Some("needs approval".into()),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
let s = observed(&fx.manager, id).await;
|
|
assert_eq!(s.activity, ActivityState::AwaitingInput);
|
|
assert_eq!(s.activity_detail.as_deref(), Some("needs approval"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn detail_is_capped_server_side() {
|
|
let fx = fixture(Duration::from_secs(300)).await;
|
|
let id = spawn_sleeping(&fx.manager).await;
|
|
|
|
let long = "x".repeat(10_000);
|
|
fx.manager
|
|
.report_activity(id, ActivityState::Error, Some(long))
|
|
.await
|
|
.unwrap();
|
|
|
|
let s = observed(&fx.manager, id).await;
|
|
let detail = s.activity_detail.expect("detail present");
|
|
assert_eq!(
|
|
detail.chars().count(),
|
|
maestro_daemon::MAX_REPORT_DETAIL_CHARS
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn reported_state_survives_watcher_ticks_until_the_ttl_then_inference_resumes() {
|
|
let fx = fixture(Duration::from_millis(150)).await;
|
|
let id = spawn_sleeping(&fx.manager).await;
|
|
fx.manager.start_liveness_watcher(Duration::from_millis(50));
|
|
|
|
fx.manager
|
|
.report_activity(id, ActivityState::Error, Some("agent died mid-run".into()))
|
|
.await
|
|
.unwrap();
|
|
|
|
// Several watcher ticks inside the TTL: nothing infers `Error`, so the
|
|
// report must hold above inference.
|
|
sleep(Duration::from_millis(80)).await;
|
|
let s = observed(&fx.manager, id).await;
|
|
assert_eq!(s.activity, ActivityState::Error);
|
|
assert_eq!(s.activity_detail.as_deref(), Some("agent died mid-run"));
|
|
|
|
// After the TTL lapses, inference resumes and the detail goes with it.
|
|
common::wait_for_async(Duration::from_secs(5), || async {
|
|
let s = observed(&fx.manager, id).await;
|
|
s.activity != ActivityState::Error && s.activity_detail.is_none()
|
|
})
|
|
.await;
|
|
}
|