Skip to main content

prospero_api/
dto.rs

1//! Request/response payloads for the HTTP API.
2//!
3//! The shared contract types now live in `prospero-types` (#172) so the WASM
4//! dashboard can use the exact same definitions — see that crate's `api` module
5//! for why. They are re-exported here from their original paths, so every import
6//! site and all serde output are unchanged.
7//!
8//! What stays in this crate is what does *not* belong in a neutral DTO crate:
9//! the axum query extractor, and the mapping from a wire body onto
10//! `prospero-core`'s domain types. ADR 0006 makes `api → core` one-directional,
11//! so that mapping is the adapter's job — pushing it into `core` (or into
12//! `prospero-types`) would drag a transport concept downward.
13
14use prospero_core::fleet::SpawnRequest;
15use prospero_core::store::UsageRow;
16use serde::Deserialize;
17
18pub use prospero_types::{
19    AddWorkspaceBody, AgentInputBody, Capabilities, OutcomeCounts, RespawnedResponse,
20    SetConfigBody, SpawnBody, SpawnedResponse, UsageBucket, UsageGroup, UsageReport,
21    WorkspaceSummary,
22};
23
24/// Query params for `GET /api/fleet/stream` (#219).
25///
26/// `from` is a *string* rather than a number because `now` is a legal value:
27/// "tail only what happens next". A number resumes after that fleet cursor.
28#[derive(Debug, Deserialize)]
29pub struct FleetFrom {
30    /// `<cursor>` | `now`. Absent replays from the beginning.
31    #[serde(default)]
32    pub from: Option<String>,
33}
34
35/// Query params for `GET /api/agents/{id}/events` and `/stream`.
36///
37/// Stays here: this is an axum extractor for a URL query string, not part of the
38/// client/server body contract.
39#[derive(Debug, Deserialize)]
40pub struct FromSeq {
41    /// Return events with `seq >= from` (default 0).
42    #[serde(default)]
43    pub from: u64,
44}
45
46/// Map a spawn request body onto the core [`SpawnRequest`].
47///
48/// A free function rather than a method because [`SpawnBody`] is defined in
49/// `prospero-types` and an inherent impl can only be written by the defining
50/// crate. Keeping the conversion here — rather than adding a `From` impl in
51/// `prospero-core` — preserves ADR 0006's one-way `api → core` dependency.
52pub fn spawn_request(body: SpawnBody) -> SpawnRequest {
53    let isolation_worktree = body.isolation_worktree();
54    SpawnRequest {
55        prompt: body.prompt,
56        label: body.label,
57        model: body.model,
58        isolation_worktree,
59        tool_allowlist: body.tool_allowlist,
60        interactive: body.interactive,
61        frontmatter_path: body.frontmatter_path.map(std::path::PathBuf::from),
62        provider_ref: body.provider_ref,
63        permission_posture: body.permission_posture,
64        timeout_secs: body.timeout_secs,
65    }
66}
67
68/// Query params for `GET /api/usage`.
69///
70/// Both bounds are optional; the handler fills in a default window and echoes
71/// back what it used. Like [`FromSeq`], this is a URL-query extractor rather
72/// than part of the body contract, so it stays in this crate.
73#[derive(Debug, Default, Deserialize)]
74pub struct UsageQuery {
75    /// Inclusive window start (RFC-3339). Defaults to 7 days before `until`.
76    pub since: Option<String>,
77    /// Exclusive window end (RFC-3339). Defaults to now.
78    pub until: Option<String>,
79    /// How many days back to look, as an alternative to `since`.
80    ///
81    /// The dashboard's window control sends this rather than a computed
82    /// timestamp so the server resolves the bound against its own clock; a
83    /// browser whose clock has drifted would otherwise silently clip or pad the
84    /// window. Ignored when `since` is given explicitly.
85    pub days: Option<i64>,
86}
87
88/// How far back `GET /api/usage` looks when neither `since` nor `days` is given.
89pub const DEFAULT_USAGE_WINDOW_DAYS: i64 = 7;
90
91/// Resolve a usage query into the `[since, until)` bounds the store is asked for.
92///
93/// The store compares event timestamps to these bounds **as strings**, so this
94/// is where the window is made safe to compare (#255):
95///
96/// - both bounds are parsed, so a value that is not a timestamp is rejected
97///   instead of selecting whatever it happens to sort between;
98/// - both are re-rendered with `to_rfc3339()`, the spelling events are stamped
99///   with. Any other spelling mis-sorts at the edge — a `Z` suffix sorts after
100///   the `.` of a fractional second, so an event half a second into the window
101///   compared as before it;
102/// - `days` is bounded before it reaches date arithmetic, which overflows (and
103///   panics) once the result leaves chrono's representable range.
104///
105/// `days` below 1 is still treated as 1, as it always has been.
106pub fn usage_window(
107    q: &UsageQuery,
108    now: chrono::DateTime<chrono::Utc>,
109) -> Result<(String, String), String> {
110    let until = match &q.until {
111        Some(raw) => parse_bound("until", raw)?,
112        None => now,
113    };
114    let since = match &q.since {
115        Some(raw) => parse_bound("since", raw)?,
116        None => {
117            let back = q.days.unwrap_or(DEFAULT_USAGE_WINDOW_DAYS).max(1);
118            if back > prospero_types::MAX_USAGE_WINDOW_DAYS {
119                return Err(format!(
120                    "days: at most {}, got {back}",
121                    prospero_types::MAX_USAGE_WINDOW_DAYS
122                ));
123            }
124            // Offset from the resolved end, not from `now`, so an explicit
125            // `until` plus `days` spans exactly `days`.
126            until
127                .checked_sub_signed(chrono::Duration::days(back))
128                .ok_or_else(|| format!("days: {back} reaches before any representable date"))?
129        }
130    };
131    Ok((since.to_rfc3339(), until.to_rfc3339()))
132}
133
134fn parse_bound(name: &str, raw: &str) -> Result<chrono::DateTime<chrono::Utc>, String> {
135    chrono::DateTime::parse_from_rfc3339(raw)
136        .map(|t| t.with_timezone(&chrono::Utc))
137        .map_err(|e| format!("{name}: {raw:?} is not an RFC-3339 timestamp ({e})"))
138}
139
140/// Fold the store's flat (workspace, day) rows into the per-workspace report.
141///
142/// The store already did the aggregation; this only reshapes. Another adapter
143/// mapping in the ADR 0006 sense — [`UsageRow`] is a `prospero-core` type and
144/// [`UsageReport`] a `prospero-types` one, and neither crate should know about
145/// the other.
146///
147/// Rows are expected in (workspace, day) order — both SQL backends `ORDER BY`
148/// that way and the in-memory fold uses a `BTreeMap` — but this does not rely on
149/// it: groups are collected by name and each series sorted before returning, so
150/// a backend that ordered differently still produces the same report.
151pub fn usage_report(rows: Vec<UsageRow>, since: &str, until: &str) -> UsageReport {
152    use std::collections::BTreeMap;
153
154    let mut groups: BTreeMap<String, UsageGroup> = BTreeMap::new();
155    for r in rows {
156        let g = groups
157            .entry(r.workspace.clone())
158            .or_insert_with(|| UsageGroup {
159                workspace: r.workspace.clone(),
160                ..UsageGroup::default()
161            });
162        g.cost_usd += r.cost_usd;
163        g.turns += r.turns;
164        g.outcomes.done += r.done;
165        g.outcomes.failed += r.failed;
166        g.outcomes.killed += r.killed;
167        g.outcomes.crashed += r.crashed;
168        g.outcomes.timed_out += r.timed_out;
169        g.series.push(UsageBucket {
170            day: r.day,
171            cost_usd: r.cost_usd,
172            turns: r.turns,
173            outcomes: OutcomeCounts {
174                done: r.done,
175                failed: r.failed,
176                killed: r.killed,
177                crashed: r.crashed,
178                timed_out: r.timed_out,
179            },
180        });
181    }
182
183    let mut groups: Vec<UsageGroup> = groups.into_values().collect();
184    for g in &mut groups {
185        g.series.sort_by(|a, b| a.day.cmp(&b.day));
186    }
187
188    UsageReport {
189        since: since.to_string(),
190        until: until.to_string(),
191        groups,
192    }
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198
199    fn row(workspace: &str, day: &str, cost: f64, turns: u64) -> UsageRow {
200        UsageRow {
201            workspace: workspace.into(),
202            day: day.into(),
203            cost_usd: cost,
204            turns,
205            done: 0,
206            failed: 0,
207            killed: 0,
208            crashed: 0,
209            timed_out: 0,
210        }
211    }
212
213    #[test]
214    fn usage_report_folds_days_into_per_workspace_totals() {
215        let rows = vec![
216            row("alpha", "2026-08-01", 0.75, 4),
217            row("alpha", "2026-08-02", 1.00, 2),
218            row("beta", "2026-08-01", 0.10, 1),
219        ];
220
221        let report = usage_report(
222            rows,
223            "2026-08-01T00:00:00+00:00",
224            "2026-08-03T00:00:00+00:00",
225        );
226
227        assert_eq!(report.since, "2026-08-01T00:00:00+00:00");
228        assert_eq!(report.until, "2026-08-03T00:00:00+00:00");
229        assert_eq!(report.groups.len(), 2);
230
231        let alpha = &report.groups[0];
232        assert_eq!(alpha.workspace, "alpha");
233        assert!((alpha.cost_usd - 1.75).abs() < 1e-9);
234        assert_eq!(alpha.turns, 6);
235        assert_eq!(
236            alpha
237                .series
238                .iter()
239                .map(|b| b.day.as_str())
240                .collect::<Vec<_>>(),
241            vec!["2026-08-01", "2026-08-02"],
242            "the series must stay ascending by day"
243        );
244
245        let beta = &report.groups[1];
246        assert_eq!(beta.workspace, "beta");
247        assert_eq!(beta.series.len(), 1);
248    }
249
250    #[test]
251    fn usage_report_sums_outcomes_across_the_window() {
252        let mut a = row("alpha", "2026-08-01", 0.0, 0);
253        a.done = 2;
254        a.killed = 1;
255        let mut b = row("alpha", "2026-08-02", 0.0, 0);
256        b.failed = 3;
257
258        let report = usage_report(vec![a, b], "s", "u");
259
260        let g = &report.groups[0];
261        assert_eq!(g.outcomes.done, 2);
262        assert_eq!(g.outcomes.killed, 1);
263        assert_eq!(g.outcomes.failed, 3);
264        assert_eq!(g.outcomes.total(), 6);
265    }
266
267    /// A killed agent never reports cost, so a workspace can show outcomes
268    /// against zero spend. The fold must preserve that rather than dropping the
269    /// group as empty.
270    #[test]
271    fn usage_report_keeps_a_workspace_with_outcomes_but_no_cost() {
272        let mut killed = row("beta", "2026-08-01", 0.0, 0);
273        killed.killed = 1;
274
275        let report = usage_report(vec![killed], "s", "u");
276
277        assert_eq!(report.groups.len(), 1);
278        assert_eq!(report.groups[0].cost_usd, 0.0);
279        assert_eq!(report.groups[0].outcomes.killed, 1);
280    }
281
282    #[test]
283    fn usage_report_over_an_empty_window_has_no_groups() {
284        let report = usage_report(Vec::new(), "s", "u");
285        assert!(report.groups.is_empty());
286    }
287
288    #[test]
289    fn spawn_body_interactive_round_trips_and_defaults_false() {
290        let with: SpawnBody = serde_json::from_str(r#"{"prompt":"p","interactive":true}"#).unwrap();
291        assert!(spawn_request(with).interactive);
292        let without: SpawnBody = serde_json::from_str(r#"{"prompt":"p"}"#).unwrap();
293        assert!(!spawn_request(without).interactive);
294    }
295
296    #[test]
297    fn spawn_body_carries_frontmatter_path() {
298        let with: SpawnBody =
299            serde_json::from_str(r#"{"prompt":"p","frontmatter_path":"/tpl.md"}"#).unwrap();
300        assert_eq!(
301            spawn_request(with).frontmatter_path,
302            Some(std::path::PathBuf::from("/tpl.md"))
303        );
304        let without: SpawnBody = serde_json::from_str(r#"{"prompt":"p"}"#).unwrap();
305        assert_eq!(spawn_request(without).frontmatter_path, None);
306    }
307
308    #[test]
309    fn spawn_defaults_to_worktree_and_only_shared_opts_out() {
310        let default: SpawnBody = serde_json::from_str(r#"{"prompt":"p"}"#).unwrap();
311        assert!(spawn_request(default).isolation_worktree);
312        let shared: SpawnBody =
313            serde_json::from_str(r#"{"prompt":"p","isolation":"shared"}"#).unwrap();
314        assert!(!spawn_request(shared).isolation_worktree);
315    }
316
317    #[test]
318    fn workspace_summary_exposes_sources() {
319        let s = WorkspaceSummary {
320            name: "ws".into(),
321            root: "/ws".into(),
322            sources: vec![prospero_core::Source {
323                name: "a".into(),
324                path: "/ws/a".into(),
325            }],
326            health: prospero_core::WorkspaceHealth::Healthy,
327            agent_count: 0,
328            config: prospero_core::registry::RepoProviderConfig::default(),
329            source_specs: Vec::new(),
330            display_name: None,
331            providers: Vec::new(),
332            default_provider: None,
333            status: None,
334        };
335        let j = serde_json::to_value(&s).unwrap();
336        assert_eq!(j["sources"][0]["name"], "a");
337        // A local workspace has no CR specs, so the key must not appear at all.
338        assert!(
339            j.get("source_specs").is_none(),
340            "local payload gained a k8s key: {j}"
341        );
342    }
343
344    /// `sources` is the *discovered* view (name + path) — all a local checkout
345    /// has. A k8s workspace additionally carries the git remote and ref, and
346    /// the v2 config editor needs those to round-trip an edit: without them an
347    /// operator would face blank remote fields and have to retype every one
348    /// from memory. (#175)
349    #[test]
350    fn source_specs_carry_the_remote_and_ref_that_sources_loses() {
351        let s = WorkspaceSummary {
352            name: "ws".into(),
353            root: String::new(),
354            sources: vec![prospero_core::Source {
355                name: "caliban".into(),
356                path: "/work/caliban".into(),
357            }],
358            health: prospero_core::WorkspaceHealth::Healthy,
359            agent_count: 0,
360            config: prospero_core::registry::RepoProviderConfig::default(),
361            source_specs: vec![prospero_types::WorkspaceSourceSpec {
362                name: "caliban".into(),
363                repo: "git@github.com:caliban-ai/caliban.git".into(),
364                r#ref: Some("main".into()),
365                path: "/work/caliban".into(),
366            }],
367            display_name: None,
368            providers: Vec::new(),
369            default_provider: None,
370            status: None,
371        };
372        let j = serde_json::to_value(&s).unwrap();
373        assert_eq!(
374            j["source_specs"][0]["repo"],
375            "git@github.com:caliban-ai/caliban.git"
376        );
377        assert_eq!(j["source_specs"][0]["ref"], "main");
378        // And it round-trips back, which is what the dashboard depends on.
379        let back: WorkspaceSummary = serde_json::from_value(j).unwrap();
380        assert_eq!(
381            back.source_specs[0].repo,
382            "git@github.com:caliban-ai/caliban.git"
383        );
384    }
385}