Skip to main content

prospero_api/
handlers.rs

1//! REST endpoint handlers over the `FleetManager`.
2
3use 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
16/// How far back `GET /api/usage` looks when the caller names no `since`.
17const DEFAULT_USAGE_WINDOW_DAYS: i64 = 7;
18
19/// `GET /api/fleet` — the whole fleet snapshot.
20pub async fn get_fleet(State(st): State<AppState>) -> Json<FleetSnapshot> {
21    Json(st.fleet.snapshot().await)
22}
23
24/// `GET /api/usage` — cost, turns, and terminal outcomes per workspace over a
25/// window, with a per-day series (#180).
26///
27/// The store does the aggregation; this only resolves the window and reshapes
28/// the rows. Bounds are compared lexically against the stored RFC-3339
29/// timestamps — the same assumption retention already makes — so the defaults
30/// are emitted with `to_rfc3339()` to match the format events are written in.
31pub 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    // An explicit `since` wins; otherwise fall back to `days`, then the default.
38    // `days` is clamped to at least 1 so `?days=0` yields an empty-but-valid
39    // window rather than one that ends before it starts.
40    let since = q.since.unwrap_or_else(|| {
41        let back = q.days.unwrap_or(DEFAULT_USAGE_WINDOW_DAYS).max(1);
42        // Offset from the resolved end, not from `now`, so an explicit `until`
43        // plus `days` spans exactly `days`.
44        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
54/// `GET /api/workspaces` — managed workspaces with health, sources, agent counts.
55pub async fn get_workspaces(State(st): State<AppState>) -> Json<Vec<WorkspaceSummary>> {
56    let snap = st.fleet.snapshot().await;
57
58    // The k8s config plane surfaces real `Workspace` CRs (config + reconciliation
59    // status), including configured-but-agentless ones the fleet snapshot can't
60    // see. `list_workspaces` is empty for the local backend, so local falls
61    // through to the snapshot projection unchanged. (#142)
62    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    // Regroup the snapshot's agents by the workspace they belong to
89    // (`agent.workspace` == the `Workspace` object name), so each config-plane
90    // workspace reports its own agent count.
91    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                // Reconciliation status (below) is the real health signal under
115                // k8s; the poll-based `health` field doesn't apply.
116                health: prospero_core::WorkspaceHealth::Healthy,
117                agent_count,
118                config: prospero_core::registry::RepoProviderConfig::default(),
119                // The full specs, so the config editor can round-trip an edit.
120                // `sources` above loses the git remote and ref.
121                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
132/// `POST /api/workspaces` — register a workspace.
133pub 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    // Async backends (k8s) only enqueued a reconcile → 202 Accepted; local
146    // applied synchronously → 201 Created.
147    Ok(if async_ops {
148        StatusCode::ACCEPTED
149    } else {
150        StatusCode::CREATED
151    })
152}
153
154/// `PUT /api/workspaces/{name}/config` — set provider config and restart caliband.
155pub 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    // k8s: the operator re-reconciles → 202 Accepted; local: applied now → 204.
167    Ok(if async_ops {
168        StatusCode::ACCEPTED
169    } else {
170        StatusCode::NO_CONTENT
171    })
172}
173
174/// `DELETE /api/workspaces/{name}` — unregister a workspace.
175pub 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
190/// `GET /api/workspaces/{workspace}/agents` — agents under one workspace.
191pub 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
202/// `POST /api/workspaces/{workspace}/agents` — spawn an agent (worktree by default).
203///
204/// Routed through the `FleetProvider` seam: `LocalFleet::ensure_agent`
205/// delegates to the same `FleetManager::spawn_agent` this handler called
206/// directly before, so behavior is unchanged.
207pub 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
232/// `GET /api/agents/{id}` — one agent's current state.
233///
234/// This reads the **live fleet snapshot** — the exact same source as
235/// `GET /api/fleet` — so the two are always consistent: an agent that has been
236/// `rm`'d, or whose id was replaced by `respawn`, disappears from *both* the
237/// fleet listing and this endpoint (404) as soon as the registry no longer
238/// tracks it (immediately for `rm` via the optimistic snapshot prune, or at the
239/// next poll for a respawn's old id). It is deliberately **not** served from the
240/// event-sourced projection: the immutable per-agent history of a replaced or
241/// removed agent remains reachable via `GET /api/agents/{id}/events`, which
242/// replays from the durable store and therefore intentionally outlives the
243/// agent's presence in the live registry (#124).
244pub 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
255/// `GET /api/agents/{id}/events?from=N` — replay history from the shared store.
256pub 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    // An agent's stream key is its own id (see `event::stream_key_for`).
262    let events = st.store.replay(&id, q.from).await?;
263    // A truly unknown agent id → 404, mirroring `GET /api/agents/{id}`. But a
264    // *known* agent with an empty replay (spawned-but-no-events-yet, or a
265    // `from` past its last seq) still returns `200 []`. Distinguish them: a
266    // known agent has durable history (high_water > 0) or is live in the fleet.
267    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
276/// `POST /api/agents/{id}/kill`.
277pub 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
287/// `POST /api/agents/{id}/respawn` — replace an agent with a fresh one.
288///
289/// Returns the **new** agent id. The old id is retired from caliban's registry,
290/// so once the next poll reconciles it disappears from both `GET /api/fleet` and
291/// `GET /api/agents/{id}` (they share the live snapshot). Its history is not
292/// destroyed: `GET /api/agents/{old_id}/events` still replays the retired
293/// agent's immutable event stream from the durable store (#124).
294pub 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
304/// `POST /api/agents/{id}/input` — inject a user message into an interactive agent.
305pub 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
319/// `POST /api/agents/{id}/end-input` — signal end-of-input to an interactive agent.
320pub 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
330/// `DELETE /api/agents/{id}` — forget the agent (local: caliban registry; k8s: CR).
331pub 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
341/// `GET /api/metrics` — prosperod's operational counters.
342pub async fn get_metrics(State(st): State<AppState>) -> Json<prospero_core::MetricsSnapshot> {
343    Json(st.fleet.metrics())
344}
345
346/// `GET /api/capabilities` — what the active fleet backend supports, so the
347/// dashboard can render backend-aware controls (#99). `admin` mirrors whether an
348/// admin/registry plane is wired (`Some` for local, `None` for k8s).
349pub 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
359/// `GET /healthz` — daemon liveness (always 200 while the process is up).
360pub async fn healthz() -> &'static str {
361    "ok"
362}
363
364/// `GET /readyz` — readiness: 200 only when the store is writable, otherwise
365/// 503 so an orchestrator can gate traffic/restarts. The body carries the
366/// store-writability flag and an aggregate repo-health summary.
367pub 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}