Skip to main content

prospero_api/
handlers.rs

1//! REST endpoint handlers over the `FleetManager`.
2
3use 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
20/// `GET /api/fleet` — the whole fleet snapshot.
21pub async fn get_fleet(State(st): State<AppState>) -> Json<FleetSnapshot> {
22    Json(st.fleet.snapshot().await)
23}
24
25/// `GET /api/usage` — cost, turns, and terminal outcomes per workspace over a
26/// window, with a per-day series (#180).
27///
28/// The store does the aggregation; this only resolves the window and reshapes
29/// the rows. Bounds are compared lexically against the stored RFC-3339
30/// timestamps — the same assumption retention already makes — so
31/// [`crate::dto::usage_window`] validates them and re-renders *every* bound,
32/// not just the defaults, in the format events are written in (#255). A bound
33/// it cannot resolve is a 400, never a report for some other window.
34pub 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
45/// `GET /api/workspaces` — managed workspaces with health, sources, agent counts.
46pub async fn get_workspaces(State(st): State<AppState>) -> Json<Vec<WorkspaceSummary>> {
47    let snap = st.fleet.snapshot().await;
48
49    // The k8s config plane surfaces real `Workspace` CRs (config + reconciliation
50    // status), including configured-but-agentless ones the fleet snapshot can't
51    // see. `list_workspaces` is empty for the local backend, so local falls
52    // through to the snapshot projection unchanged. (#142)
53    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    // Regroup the snapshot's agents by the workspace they belong to
80    // (`agent.workspace` == the `Workspace` object name), so each config-plane
81    // workspace reports its own agent count.
82    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                // Reconciliation status (below) is the real health signal under
106                // k8s; the poll-based `health` field doesn't apply.
107                health: prospero_core::WorkspaceHealth::Healthy,
108                agent_count,
109                config: prospero_core::registry::RepoProviderConfig::default(),
110                // The full specs, so the config editor can round-trip an edit.
111                // `sources` above loses the git remote and ref.
112                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
123/// `POST /api/workspaces` — register a workspace.
124pub 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    // Async backends (k8s) only enqueued a reconcile → 202 Accepted; local
137    // applied synchronously → 201 Created.
138    Ok(if async_ops {
139        StatusCode::ACCEPTED
140    } else {
141        StatusCode::CREATED
142    })
143}
144
145/// `PUT /api/workspaces/{name}/config` — set provider config and restart caliband.
146pub 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    // k8s: the operator re-reconciles → 202 Accepted; local: applied now → 204.
158    Ok(if async_ops {
159        StatusCode::ACCEPTED
160    } else {
161        StatusCode::NO_CONTENT
162    })
163}
164
165/// `DELETE /api/workspaces/{name}` — unregister a workspace.
166pub 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
181/// `GET /api/workspaces/{workspace}/agents` — agents under one workspace.
182pub 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
193/// `POST /api/workspaces/{workspace}/agents` — spawn an agent (worktree by default).
194///
195/// Routed through the `FleetProvider` seam: `LocalFleet::ensure_agent`
196/// delegates to the same `FleetManager::spawn_agent` this handler called
197/// directly before, so behavior is unchanged.
198/// `unattended` (#238) runs the agent with no permission gate, so it takes the
199/// `admin` scope (ADR-0010) rather than the `operate` a spawn otherwise needs —
200/// a per-field rule the route-level scope table can't express. The principal is
201/// absent only when auth is disabled entirely, which is already a trusted
202/// loopback deployment, so that case is allowed and still audited.
203pub 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
248/// `GET /api/agents/{id}` — one agent's current state.
249///
250/// This reads the **live fleet snapshot** — the exact same source as
251/// `GET /api/fleet` — so the two are always consistent: an agent that has been
252/// `rm`'d, or whose id was replaced by `respawn`, disappears from *both* the
253/// fleet listing and this endpoint (404) as soon as the registry no longer
254/// tracks it (immediately for `rm` via the optimistic snapshot prune, or at the
255/// next poll for a respawn's old id). It is deliberately **not** served from the
256/// event-sourced projection: the immutable per-agent history of a replaced or
257/// removed agent remains reachable via `GET /api/agents/{id}/events`, which
258/// replays from the durable store and therefore intentionally outlives the
259/// agent's presence in the live registry (#124).
260pub 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
271/// `GET /api/agents/{id}/events?from=N` — replay history from the shared store.
272pub 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    // An agent's stream key is its own id (see `event::stream_key_for`).
278    let events = st.store.replay(&id, q.from).await?;
279    // A truly unknown agent id → 404, mirroring `GET /api/agents/{id}`. But a
280    // *known* agent with an empty replay (spawned-but-no-events-yet, or a
281    // `from` past its last seq) still returns `200 []`. Distinguish them: a
282    // known agent has durable history (high_water > 0) or is live in the fleet.
283    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
292/// `POST /api/agents/{id}/kill`.
293pub 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
303/// `POST /api/agents/{id}/respawn` — replace an agent with a fresh one.
304///
305/// Returns the **new** agent id. The old id is retired from caliban's registry,
306/// so once the next poll reconciles it disappears from both `GET /api/fleet` and
307/// `GET /api/agents/{id}` (they share the live snapshot). Its history is not
308/// destroyed: `GET /api/agents/{old_id}/events` still replays the retired
309/// agent's immutable event stream from the durable store (#124).
310pub 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
320/// `POST /api/agents/{id}/input` — inject a user message into an interactive agent.
321pub 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
335/// `POST /api/agents/{id}/end-input` — signal end-of-input to an interactive agent.
336pub 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
346/// `DELETE /api/agents/{id}` — forget the agent (local: caliban registry; k8s: CR).
347pub 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
357/// `GET /api/metrics` — prosperod's operational counters.
358pub async fn get_metrics(State(st): State<AppState>) -> Json<prospero_core::MetricsSnapshot> {
359    Json(st.fleet.metrics())
360}
361
362/// `GET /api/capabilities` — what the active fleet backend supports, so the
363/// dashboard can render backend-aware controls (#99). `admin` mirrors whether an
364/// admin/registry plane is wired (`Some` for local, `None` for k8s).
365pub 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
375/// `GET /healthz` — daemon liveness (always 200 while the process is up).
376pub async fn healthz() -> &'static str {
377    "ok"
378}
379
380/// `GET /readyz` — readiness: 200 only when the store is writable, otherwise
381/// 503 so an orchestrator can gate traffic/restarts. The body carries the
382/// store-writability flag and an aggregate repo-health summary.
383pub 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}