1use axum::Json;
4use axum::extract::{Extension, Path, Query, State};
5use axum::http::StatusCode;
6use prospero_core::AttachInbound;
7use prospero_core::model::{
8 Agent, AgentId, DrainPolicy, FleetSnapshot, PermissionPosture, TaskSpec,
9};
10use prospero_types::Scope;
11
12use crate::AppState;
13use crate::auth::Principal;
14use crate::dto::{
15 AddWorkspaceBody, AgentInputBody, FromSeq, RespawnedResponse, SetConfigBody, SpawnBody,
16 SpawnedResponse, UsageQuery, UsageReport, WorkspaceSummary,
17};
18use crate::error::ApiError;
19
20pub async fn get_fleet(State(st): State<AppState>) -> Json<FleetSnapshot> {
22 Json(st.fleet.snapshot().await)
23}
24
25pub async fn get_usage(
35 State(st): State<AppState>,
36 Query(q): Query<UsageQuery>,
37) -> Result<Json<UsageReport>, ApiError> {
38 let (since, until) =
39 crate::dto::usage_window(&q, chrono::Utc::now()).map_err(ApiError::BadRequest)?;
40
41 let rows = st.store.usage(&since, &until).await?;
42 Ok(Json(crate::dto::usage_report(rows, &since, &until)))
43}
44
45pub async fn get_workspaces(State(st): State<AppState>) -> Json<Vec<WorkspaceSummary>> {
47 let snap = st.fleet.snapshot().await;
48
49 let records = match st.admin.as_ref() {
54 Some(admin) => admin.list_workspaces().await.unwrap_or_default(),
55 None => Vec::new(),
56 };
57
58 if records.is_empty() {
59 let out = snap
60 .workspaces
61 .into_iter()
62 .map(|r| WorkspaceSummary {
63 name: r.name,
64 root: r.root.display().to_string(),
65 sources: r.sources,
66 health: r.health,
67 agent_count: r.agents.len(),
68 config: r.config,
69 source_specs: Vec::new(),
70 display_name: None,
71 providers: Vec::new(),
72 default_provider: None,
73 status: None,
74 })
75 .collect();
76 return Json(out);
77 }
78
79 let mut agent_counts: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
83 for w in &snap.workspaces {
84 for a in &w.agents {
85 *agent_counts.entry(a.workspace.as_str()).or_default() += 1;
86 }
87 }
88
89 let out = records
90 .into_iter()
91 .map(|wi| {
92 let agent_count = agent_counts.get(wi.name.as_str()).copied().unwrap_or(0);
93 let sources = wi
94 .sources
95 .iter()
96 .map(|s| prospero_core::Source {
97 name: s.name.clone(),
98 path: std::path::PathBuf::from(&s.path),
99 })
100 .collect();
101 WorkspaceSummary {
102 name: wi.name,
103 root: String::new(),
104 sources,
105 health: prospero_core::WorkspaceHealth::Healthy,
108 agent_count,
109 config: prospero_core::registry::RepoProviderConfig::default(),
110 source_specs: wi.sources,
113 display_name: wi.display_name,
114 providers: wi.providers,
115 default_provider: wi.default_provider,
116 status: wi.status,
117 }
118 })
119 .collect();
120 Json(out)
121}
122
123pub async fn add_workspace(
125 State(st): State<AppState>,
126 Json(body): Json<AddWorkspaceBody>,
127) -> Result<StatusCode, ApiError> {
128 let admin = st
129 .admin
130 .as_ref()
131 .ok_or_else(ApiError::unsupported_on_backend)?;
132 let async_ops = admin.workspace_ops_are_async();
133 admin
134 .add_workspace(body.name, body.root.into(), body.config)
135 .await?;
136 Ok(if async_ops {
139 StatusCode::ACCEPTED
140 } else {
141 StatusCode::CREATED
142 })
143}
144
145pub async fn set_workspace_config(
147 State(st): State<AppState>,
148 Path(name): Path<String>,
149 Json(body): Json<SetConfigBody>,
150) -> Result<StatusCode, ApiError> {
151 let admin = st
152 .admin
153 .as_ref()
154 .ok_or_else(ApiError::unsupported_on_backend)?;
155 let async_ops = admin.workspace_ops_are_async();
156 admin.set_workspace_config(&name, body.0).await?;
157 Ok(if async_ops {
159 StatusCode::ACCEPTED
160 } else {
161 StatusCode::NO_CONTENT
162 })
163}
164
165pub async fn delete_workspace(
167 State(st): State<AppState>,
168 Path(name): Path<String>,
169) -> Result<StatusCode, ApiError> {
170 let admin = st
171 .admin
172 .as_ref()
173 .ok_or_else(ApiError::unsupported_on_backend)?;
174 if admin.remove_workspace(&name).await? {
175 Ok(StatusCode::NO_CONTENT)
176 } else {
177 Err(prospero_core::CoreError::WorkspaceNotFound(name).into())
178 }
179}
180
181pub async fn get_workspace_agents(
183 State(st): State<AppState>,
184 Path(workspace): Path<String>,
185) -> Result<Json<Vec<Agent>>, ApiError> {
186 let snap = st.fleet.snapshot().await;
187 match snap.workspaces.into_iter().find(|r| r.name == workspace) {
188 Some(r) => Ok(Json(r.agents)),
189 None => Err(prospero_core::CoreError::WorkspaceNotFound(workspace).into()),
190 }
191}
192
193pub async fn spawn_agent(
204 State(st): State<AppState>,
205 Path(workspace): Path<String>,
206 principal: Option<Extension<Principal>>,
207 Json(body): Json<SpawnBody>,
208) -> Result<(StatusCode, Json<SpawnedResponse>), ApiError> {
209 if body.permission_posture == PermissionPosture::Unattended {
210 if let Some(Extension(p)) = &principal
211 && p.scope < Scope::Admin
212 {
213 tracing::debug!(
214 target: "prospero_auth", token = %p.token_name, have = p.scope.as_str(),
215 "rejected: unattended spawn requires admin scope"
216 );
217 return Err(ApiError::Forbidden(
218 "unattended requires admin scope".to_string(),
219 ));
220 }
221 tracing::info!(
222 target: "prospero_audit",
223 actor = principal.as_ref().map_or("-", |Extension(p)| p.token_name.as_str()),
224 workspace = %workspace,
225 "spawn: unattended permission posture granted"
226 );
227 }
228 let req = crate::dto::spawn_request(body);
229 let isolated = req.isolation_worktree;
230 let handle = st
231 .fleet
232 .ensure_agent(TaskSpec {
233 workspace: workspace.clone(),
234 request: req,
235 })
236 .await?;
237 Ok((
238 StatusCode::CREATED,
239 Json(SpawnedResponse {
240 agent_id: handle.id.to_string(),
241 workspace,
242 isolated,
243 created: handle.created,
244 }),
245 ))
246}
247
248pub async fn get_agent(
261 State(st): State<AppState>,
262 Path(id): Path<String>,
263) -> Result<Json<Agent>, ApiError> {
264 let snap = st.fleet.snapshot().await;
265 match snap.find_agent(&id) {
266 Some((_, agent)) => Ok(Json(agent.clone())),
267 None => Err(prospero_core::CoreError::AgentNotFound(id).into()),
268 }
269}
270
271pub async fn get_agent_events(
273 State(st): State<AppState>,
274 Path(id): Path<String>,
275 Query(q): Query<FromSeq>,
276) -> Result<Json<Vec<prospero_core::FleetEvent>>, ApiError> {
277 let events = st.store.replay(&id, q.from).await?;
279 if events.is_empty()
284 && st.store.high_water(&id).await? == 0
285 && st.fleet.snapshot().await.find_agent(&id).is_none()
286 {
287 return Err(prospero_core::CoreError::AgentNotFound(id).into());
288 }
289 Ok(Json(events))
290}
291
292pub async fn kill_agent(
294 State(st): State<AppState>,
295 Path(id): Path<String>,
296) -> Result<StatusCode, ApiError> {
297 st.fleet
298 .stop_agent(&AgentId::from(id.as_str()), DrainPolicy::Kill)
299 .await?;
300 Ok(StatusCode::ACCEPTED)
301}
302
303pub async fn respawn_agent(
311 State(st): State<AppState>,
312 Path(id): Path<String>,
313) -> Result<Json<RespawnedResponse>, ApiError> {
314 let new_id = st.fleet.restart_agent(&AgentId::from(id.as_str())).await?;
315 Ok(Json(RespawnedResponse {
316 agent_id: new_id.to_string(),
317 }))
318}
319
320pub async fn agent_input(
322 State(st): State<AppState>,
323 Path(id): Path<String>,
324 Json(body): Json<AgentInputBody>,
325) -> Result<StatusCode, ApiError> {
326 st.fleet
327 .send_input(
328 &AgentId::from(id.as_str()),
329 AttachInbound::UserMessage { text: body.text },
330 )
331 .await?;
332 Ok(StatusCode::ACCEPTED)
333}
334
335pub async fn agent_end_input(
337 State(st): State<AppState>,
338 Path(id): Path<String>,
339) -> Result<StatusCode, ApiError> {
340 st.fleet
341 .send_input(&AgentId::from(id.as_str()), AttachInbound::EndInput)
342 .await?;
343 Ok(StatusCode::ACCEPTED)
344}
345
346pub async fn rm_agent(
348 State(st): State<AppState>,
349 Path(id): Path<String>,
350) -> Result<StatusCode, ApiError> {
351 st.fleet
352 .remove_agent(&AgentId::from(id.as_str()), false)
353 .await?;
354 Ok(StatusCode::NO_CONTENT)
355}
356
357pub async fn get_metrics(State(st): State<AppState>) -> Json<prospero_core::MetricsSnapshot> {
359 Json(st.fleet.metrics())
360}
361
362pub async fn get_capabilities(State(st): State<AppState>) -> Json<crate::dto::Capabilities> {
366 Json(crate::dto::Capabilities {
367 admin: st.admin.is_some(),
368 async_workspace_ops: st
369 .admin
370 .as_ref()
371 .is_some_and(|a| a.workspace_ops_are_async()),
372 })
373}
374
375pub async fn healthz() -> &'static str {
377 "ok"
378}
379
380pub async fn readyz(State(st): State<AppState>) -> (StatusCode, Json<prospero_core::Readiness>) {
384 let readiness = st.fleet.readiness().await;
385 let code = if readiness.ready {
386 StatusCode::OK
387 } else {
388 StatusCode::SERVICE_UNAVAILABLE
389 };
390 (code, Json(readiness))
391}