feat(maestro): Project wire type and PROJECTS route (#54)

This commit is contained in:
Miguel Palhas
2026-08-23 20:32:29 +01:00
parent b6cf87244f
commit 33fb50be6e
3 changed files with 61 additions and 2 deletions
+6 -1
View File
@@ -15,7 +15,8 @@ use uuid::Uuid;
use crate::routes::{self, UrlError};
use crate::{
ApiError, CreateSessionRequest, DaemonInfo, DirListing, ErrorInfo, GitRoot, RecentDirs, Session,
ApiError, CreateSessionRequest, DaemonInfo, DirListing, ErrorInfo, GitRoot, Project,
RecentDirs, Session,
};
/// Time budget for establishing a TCP connection to the daemon.
@@ -138,6 +139,10 @@ impl DaemonClient {
.await
}
pub async fn list_projects(&self) -> Result<Vec<Project>, ClientError> {
self.get(routes::PROJECTS, &[]).await
}
// -- plumbing -----------------------------------------------------------
async fn get<T: DeserializeOwned>(
+52
View File
@@ -188,6 +188,30 @@ pub struct GitRoot {
pub root: Option<PathBuf>,
}
/// One discovered git repository (or linked worktree) for `GET /api/projects`
/// (#54). The daemon reports the timestamp; clients decide what "stale" means
/// (the web UI dims entries older than 7 days).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Project {
/// Absolute path of the repository/worktree toplevel.
pub path: PathBuf,
/// Basename of `path`.
pub name: String,
#[serde(default)]
pub is_worktree: bool,
/// The main checkout this worktree belongs to. Set only when
/// [`Self::is_worktree`].
#[serde(default, skip_serializing_if = "Option::is_none")]
pub base_repo: Option<PathBuf>,
/// Later of the repo's git HEAD mtime and the newest spawn-history use of
/// any directory inside it. `None` when neither is known.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_activity: Option<DateTime<Utc>>,
/// Command last spawned in this project, from spawn history.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_command: Option<String>,
}
/// Structured failure used everywhere we don't want to leak a string-only error.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ErrorInfo {
@@ -329,6 +353,34 @@ mod tests {
serde_json::from_str(&json).expect("deserialize")
}
#[test]
fn project_round_trips_and_defaults_on_legacy_payload() {
let full = Project {
path: PathBuf::from("/home/u/tea/maestro"),
name: "maestro".into(),
is_worktree: true,
base_repo: Some(PathBuf::from("/home/u/tea/maestro-main")),
last_activity: Some(
DateTime::parse_from_rfc3339("2026-08-23T12:00:00Z")
.unwrap()
.with_timezone(&Utc),
),
last_command: Some("claude".into()),
};
assert_eq!(full, round_trip(&full));
// A payload from a daemon predating the optional fields still decodes.
let legacy = serde_json::json!({
"path": "/home/u/proj",
"name": "proj",
});
let p: Project = serde_json::from_value(legacy).unwrap();
assert!(!p.is_worktree);
assert_eq!(p.base_repo, None);
assert_eq!(p.last_activity, None);
assert_eq!(p.last_command, None);
}
fn sample_session() -> Session {
Session {
id: Uuid::from_u128(0x0123_4567_89ab_cdef_0123_4567_89ab_cdef),
+3 -1
View File
@@ -17,6 +17,8 @@ pub const INFO: &str = "/api/info";
pub const DIRS: &str = "/api/dirs";
/// `GET` spawn history.
pub const RECENT_DIRS: &str = "/api/recent-dirs";
/// `GET` discovered git repositories and worktrees, newest activity first.
pub const PROJECTS: &str = "/api/projects";
/// `GET` the enclosing repository of a path. Takes a `path` query param.
pub const GITROOT: &str = "/api/gitroot";
/// `WS` catalog snapshot + session deltas.
@@ -172,7 +174,7 @@ mod tests {
#[test]
fn every_route_sits_under_the_api_prefix() {
for path in [SESSIONS, INFO, DIRS, RECENT_DIRS, GITROOT, EVENTS] {
for path in [SESSIONS, INFO, DIRS, RECENT_DIRS, PROJECTS, GITROOT, EVENTS] {
assert!(path.starts_with(API_PREFIX), "{path} escapes {API_PREFIX}");
}
assert!(attach(id()).starts_with(API_PREFIX));