1use axum::Json;
4use axum::extract::{Path, Query, State};
5use axum::http::StatusCode;
6use prospero_core::AttachInbound;
7use prospero_core::model::{Agent, AgentId, DrainPolicy, FleetSnapshot, TaskSpec};
8
9use crate::AppState;
10use crate::dto::{
11 AddWorkspaceBody, AgentInputBody, FromSeq, RespawnedResponse, SetConfigBody, SpawnBody,
12 SpawnedResponse, UsageQuery, UsageReport, WorkspaceSummary,
13};
14use crate::error::ApiError;
15
16const DEFAULT_USAGE_WINDOW_DAYS: i64 = 7;
18
19pub async fn get_fleet(State(st): State<AppState>) -> Json<FleetSnapshot> {
21 Json(st.fleet.snapshot().await)
22}
23
24pub async fn get_usage(
32 State(st): State<AppState>,
33 Query(q): Query<UsageQuery>,
34) -> Result<Json<UsageReport>, ApiError> {
35 let now = chrono::Utc::now();
36 let until = q.until.unwrap_or_else(|| now.to_rfc3339());
37 let since = q.since.unwrap_or_else(|| {
41 let back = q.days.unwrap_or(DEFAULT_USAGE_WINDOW_DAYS).max(1);
42 let end = chrono::DateTime::parse_from_rfc3339(&until)
45 .map(|t| t.with_timezone(&chrono::Utc))
46 .unwrap_or(now);
47 (end - chrono::Duration::days(back)).to_rfc3339()
48 });
49
50 let rows = st.store.usage(&since, &until).await?;
51 Ok(Json(crate::dto::usage_report(rows, &since, &until)))
52}
53
54pub async fn get_workspaces(State(st): State<AppState>) -> Json<Vec<WorkspaceSummary>> {
56 let snap = st.fleet.snapshot().await;
57
58 let records = match st.admin.as_ref() {
63 Some(admin) => admin.list_workspaces().await.unwrap_or_default(),
64 None => Vec::new(),
65 };
66
67 if records.is_empty() {
68 let out = snap
69 .workspaces
70 .into_iter()
71 .map(|r| WorkspaceSummary {
72 name: r.name,
73 root: r.root.display().to_string(),
74 sources: r.sources,
75 health: r.health,
76 agent_count: r.agents.len(),
77 config: r.config,
78 source_specs: Vec::new(),
79 display_name: None,
80 providers: Vec::new(),
81 default_provider: None,
82 status: None,
83 })
84 .collect();
85 return Json(out);
86 }
87
88 let mut agent_counts: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
92 for w in &snap.workspaces {
93 for a in &w.agents {
94 *agent_counts.entry(a.workspace.as_str()).or_default() += 1;
95 }
96 }
97
98 let out = records
99 .into_iter()
100 .map(|wi| {
101 let agent_count = agent_counts.get(wi.name.as_str()).copied().unwrap_or(0);
102 let sources = wi
103 .sources
104 .iter()
105 .map(|s| prospero_core::Source {
106 name: s.name.clone(),
107 path: std::path::PathBuf::from(&s.path),
108 })
109 .collect();
110 WorkspaceSummary {
111 name: wi.name,
112 root: String::new(),
113 sources,
114 health: prospero_core::WorkspaceHealth::Healthy,
117 agent_count,
118 config: prospero_core::registry::RepoProviderConfig::default(),
119 source_specs: wi.sources,
122 display_name: wi.display_name,
123 providers: wi.providers,
124 default_provider: wi.default_provider,
125 status: wi.status,
126 }
127 })
128 .collect();
129 Json(out)
130}
131
132pub async fn add_workspace(
134 State(st): State<AppState>,
135 Json(body): Json<AddWorkspaceBody>,
136) -> Result<StatusCode, ApiError> {
137 let admin = st
138 .admin
139 .as_ref()
140 .ok_or_else(ApiError::unsupported_on_backend)?;
141 let async_ops = admin.workspace_ops_are_async();
142 admin
143 .add_workspace(body.name, body.root.into(), body.config)
144 .await?;
145 Ok(if async_ops {
148 StatusCode::ACCEPTED
149 } else {
150 StatusCode::CREATED
151 })
152}
153
154pub async fn set_workspace_config(
156 State(st): State<AppState>,
157 Path(name): Path<String>,
158 Json(body): Json<SetConfigBody>,
159) -> Result<StatusCode, ApiError> {
160 let admin = st
161 .admin
162 .as_ref()
163 .ok_or_else(ApiError::unsupported_on_backend)?;
164 let async_ops = admin.workspace_ops_are_async();
165 admin.set_workspace_config(&name, body.0).await?;
166 Ok(if async_ops {
168 StatusCode::ACCEPTED
169 } else {
170 StatusCode::NO_CONTENT
171 })
172}
173
174pub async fn delete_workspace(
176 State(st): State<AppState>,
177 Path(name): Path<String>,
178) -> Result<StatusCode, ApiError> {
179 let admin = st
180 .admin
181 .as_ref()
182 .ok_or_else(ApiError::unsupported_on_backend)?;
183 if admin.remove_workspace(&name).await? {
184 Ok(StatusCode::NO_CONTENT)
185 } else {
186 Err(prospero_core::CoreError::WorkspaceNotFound(name).into())
187 }
188}
189
190pub async fn get_workspace_agents(
192 State(st): State<AppState>,
193 Path(workspace): Path<String>,
194) -> Result<Json<Vec<Agent>>, ApiError> {
195 let snap = st.fleet.snapshot().await;
196 match snap.workspaces.into_iter().find(|r| r.name == workspace) {
197 Some(r) => Ok(Json(r.agents)),
198 None => Err(prospero_core::CoreError::WorkspaceNotFound(workspace).into()),
199 }
200}
201
202pub async fn spawn_agent(
208 State(st): State<AppState>,
209 Path(workspace): Path<String>,
210 Json(body): Json<SpawnBody>,
211) -> Result<(StatusCode, Json<SpawnedResponse>), ApiError> {
212 let req = crate::dto::spawn_request(body);
213 let isolated = req.isolation_worktree;
214 let handle = st
215 .fleet
216 .ensure_agent(TaskSpec {
217 workspace: workspace.clone(),
218 request: req,
219 })
220 .await?;
221 Ok((
222 StatusCode::CREATED,
223 Json(SpawnedResponse {
224 agent_id: handle.id.to_string(),
225 workspace,
226 isolated,
227 created: handle.created,
228 }),
229 ))
230}
231
232pub async fn get_agent(
245 State(st): State<AppState>,
246 Path(id): Path<String>,
247) -> Result<Json<Agent>, ApiError> {
248 let snap = st.fleet.snapshot().await;
249 match snap.find_agent(&id) {
250 Some((_, agent)) => Ok(Json(agent.clone())),
251 None => Err(prospero_core::CoreError::AgentNotFound(id).into()),
252 }
253}
254
255pub async fn get_agent_events(
257 State(st): State<AppState>,
258 Path(id): Path<String>,
259 Query(q): Query<FromSeq>,
260) -> Result<Json<Vec<prospero_core::FleetEvent>>, ApiError> {
261 let events = st.store.replay(&id, q.from).await?;
263 if events.is_empty()
268 && st.store.high_water(&id).await? == 0
269 && st.fleet.snapshot().await.find_agent(&id).is_none()
270 {
271 return Err(prospero_core::CoreError::AgentNotFound(id).into());
272 }
273 Ok(Json(events))
274}
275
276pub async fn kill_agent(
278 State(st): State<AppState>,
279 Path(id): Path<String>,
280) -> Result<StatusCode, ApiError> {
281 st.fleet
282 .stop_agent(&AgentId::from(id.as_str()), DrainPolicy::Kill)
283 .await?;
284 Ok(StatusCode::ACCEPTED)
285}
286
287pub async fn respawn_agent(
295 State(st): State<AppState>,
296 Path(id): Path<String>,
297) -> Result<Json<RespawnedResponse>, ApiError> {
298 let new_id = st.fleet.restart_agent(&AgentId::from(id.as_str())).await?;
299 Ok(Json(RespawnedResponse {
300 agent_id: new_id.to_string(),
301 }))
302}
303
304pub async fn agent_input(
306 State(st): State<AppState>,
307 Path(id): Path<String>,
308 Json(body): Json<AgentInputBody>,
309) -> Result<StatusCode, ApiError> {
310 st.fleet
311 .send_input(
312 &AgentId::from(id.as_str()),
313 AttachInbound::UserMessage { text: body.text },
314 )
315 .await?;
316 Ok(StatusCode::ACCEPTED)
317}
318
319pub async fn agent_end_input(
321 State(st): State<AppState>,
322 Path(id): Path<String>,
323) -> Result<StatusCode, ApiError> {
324 st.fleet
325 .send_input(&AgentId::from(id.as_str()), AttachInbound::EndInput)
326 .await?;
327 Ok(StatusCode::ACCEPTED)
328}
329
330pub async fn rm_agent(
332 State(st): State<AppState>,
333 Path(id): Path<String>,
334) -> Result<StatusCode, ApiError> {
335 st.fleet
336 .remove_agent(&AgentId::from(id.as_str()), false)
337 .await?;
338 Ok(StatusCode::NO_CONTENT)
339}
340
341pub async fn get_metrics(State(st): State<AppState>) -> Json<prospero_core::MetricsSnapshot> {
343 Json(st.fleet.metrics())
344}
345
346pub async fn get_capabilities(State(st): State<AppState>) -> Json<crate::dto::Capabilities> {
350 Json(crate::dto::Capabilities {
351 admin: st.admin.is_some(),
352 async_workspace_ops: st
353 .admin
354 .as_ref()
355 .is_some_and(|a| a.workspace_ops_are_async()),
356 })
357}
358
359pub async fn healthz() -> &'static str {
361 "ok"
362}
363
364pub async fn readyz(State(st): State<AppState>) -> (StatusCode, Json<prospero_core::Readiness>) {
368 let readiness = st.fleet.readiness().await;
369 let code = if readiness.ready {
370 StatusCode::OK
371 } else {
372 StatusCode::SERVICE_UNAVAILABLE
373 };
374 (code, Json(readiness))
375}