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/agents/{id}/events` and `/stream`.
25///
26/// Stays here: this is an axum extractor for a URL query string, not part of the
27/// client/server body contract.
28#[derive(Debug, Deserialize)]
29pub struct FromSeq {
30    /// Return events with `seq >= from` (default 0).
31    #[serde(default)]
32    pub from: u64,
33}
34
35/// Map a spawn request body onto the core [`SpawnRequest`].
36///
37/// A free function rather than a method because [`SpawnBody`] is defined in
38/// `prospero-types` and an inherent impl can only be written by the defining
39/// crate. Keeping the conversion here — rather than adding a `From` impl in
40/// `prospero-core` — preserves ADR 0006's one-way `api → core` dependency.
41pub fn spawn_request(body: SpawnBody) -> SpawnRequest {
42    let isolation_worktree = body.isolation_worktree();
43    SpawnRequest {
44        prompt: body.prompt,
45        label: body.label,
46        model: body.model,
47        isolation_worktree,
48        tool_allowlist: body.tool_allowlist,
49        interactive: body.interactive,
50        frontmatter_path: body.frontmatter_path.map(std::path::PathBuf::from),
51        provider_ref: body.provider_ref,
52    }
53}
54
55/// Query params for `GET /api/usage`.
56///
57/// Both bounds are optional; the handler fills in a default window and echoes
58/// back what it used. Like [`FromSeq`], this is a URL-query extractor rather
59/// than part of the body contract, so it stays in this crate.
60#[derive(Debug, Default, Deserialize)]
61pub struct UsageQuery {
62    /// Inclusive window start (RFC-3339). Defaults to 7 days before `until`.
63    pub since: Option<String>,
64    /// Exclusive window end (RFC-3339). Defaults to now.
65    pub until: Option<String>,
66    /// How many days back to look, as an alternative to `since`.
67    ///
68    /// The dashboard's window control sends this rather than a computed
69    /// timestamp so the server resolves the bound against its own clock; a
70    /// browser whose clock has drifted would otherwise silently clip or pad the
71    /// window. Ignored when `since` is given explicitly.
72    pub days: Option<i64>,
73}
74
75/// Fold the store's flat (workspace, day) rows into the per-workspace report.
76///
77/// The store already did the aggregation; this only reshapes. Another adapter
78/// mapping in the ADR 0006 sense — [`UsageRow`] is a `prospero-core` type and
79/// [`UsageReport`] a `prospero-types` one, and neither crate should know about
80/// the other.
81///
82/// Rows are expected in (workspace, day) order — both SQL backends `ORDER BY`
83/// that way and the in-memory fold uses a `BTreeMap` — but this does not rely on
84/// it: groups are collected by name and each series sorted before returning, so
85/// a backend that ordered differently still produces the same report.
86pub fn usage_report(rows: Vec<UsageRow>, since: &str, until: &str) -> UsageReport {
87    use std::collections::BTreeMap;
88
89    let mut groups: BTreeMap<String, UsageGroup> = BTreeMap::new();
90    for r in rows {
91        let g = groups
92            .entry(r.workspace.clone())
93            .or_insert_with(|| UsageGroup {
94                workspace: r.workspace.clone(),
95                ..UsageGroup::default()
96            });
97        g.cost_usd += r.cost_usd;
98        g.turns += r.turns;
99        g.outcomes.done += r.done;
100        g.outcomes.failed += r.failed;
101        g.outcomes.killed += r.killed;
102        g.outcomes.crashed += r.crashed;
103        g.series.push(UsageBucket {
104            day: r.day,
105            cost_usd: r.cost_usd,
106            turns: r.turns,
107            outcomes: OutcomeCounts {
108                done: r.done,
109                failed: r.failed,
110                killed: r.killed,
111                crashed: r.crashed,
112            },
113        });
114    }
115
116    let mut groups: Vec<UsageGroup> = groups.into_values().collect();
117    for g in &mut groups {
118        g.series.sort_by(|a, b| a.day.cmp(&b.day));
119    }
120
121    UsageReport {
122        since: since.to_string(),
123        until: until.to_string(),
124        groups,
125    }
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131
132    fn row(workspace: &str, day: &str, cost: f64, turns: u64) -> UsageRow {
133        UsageRow {
134            workspace: workspace.into(),
135            day: day.into(),
136            cost_usd: cost,
137            turns,
138            done: 0,
139            failed: 0,
140            killed: 0,
141            crashed: 0,
142        }
143    }
144
145    #[test]
146    fn usage_report_folds_days_into_per_workspace_totals() {
147        let rows = vec![
148            row("alpha", "2026-08-01", 0.75, 4),
149            row("alpha", "2026-08-02", 1.00, 2),
150            row("beta", "2026-08-01", 0.10, 1),
151        ];
152
153        let report = usage_report(
154            rows,
155            "2026-08-01T00:00:00+00:00",
156            "2026-08-03T00:00:00+00:00",
157        );
158
159        assert_eq!(report.since, "2026-08-01T00:00:00+00:00");
160        assert_eq!(report.until, "2026-08-03T00:00:00+00:00");
161        assert_eq!(report.groups.len(), 2);
162
163        let alpha = &report.groups[0];
164        assert_eq!(alpha.workspace, "alpha");
165        assert!((alpha.cost_usd - 1.75).abs() < 1e-9);
166        assert_eq!(alpha.turns, 6);
167        assert_eq!(
168            alpha
169                .series
170                .iter()
171                .map(|b| b.day.as_str())
172                .collect::<Vec<_>>(),
173            vec!["2026-08-01", "2026-08-02"],
174            "the series must stay ascending by day"
175        );
176
177        let beta = &report.groups[1];
178        assert_eq!(beta.workspace, "beta");
179        assert_eq!(beta.series.len(), 1);
180    }
181
182    #[test]
183    fn usage_report_sums_outcomes_across_the_window() {
184        let mut a = row("alpha", "2026-08-01", 0.0, 0);
185        a.done = 2;
186        a.killed = 1;
187        let mut b = row("alpha", "2026-08-02", 0.0, 0);
188        b.failed = 3;
189
190        let report = usage_report(vec![a, b], "s", "u");
191
192        let g = &report.groups[0];
193        assert_eq!(g.outcomes.done, 2);
194        assert_eq!(g.outcomes.killed, 1);
195        assert_eq!(g.outcomes.failed, 3);
196        assert_eq!(g.outcomes.total(), 6);
197    }
198
199    /// A killed agent never reports cost, so a workspace can show outcomes
200    /// against zero spend. The fold must preserve that rather than dropping the
201    /// group as empty.
202    #[test]
203    fn usage_report_keeps_a_workspace_with_outcomes_but_no_cost() {
204        let mut killed = row("beta", "2026-08-01", 0.0, 0);
205        killed.killed = 1;
206
207        let report = usage_report(vec![killed], "s", "u");
208
209        assert_eq!(report.groups.len(), 1);
210        assert_eq!(report.groups[0].cost_usd, 0.0);
211        assert_eq!(report.groups[0].outcomes.killed, 1);
212    }
213
214    #[test]
215    fn usage_report_over_an_empty_window_has_no_groups() {
216        let report = usage_report(Vec::new(), "s", "u");
217        assert!(report.groups.is_empty());
218    }
219
220    #[test]
221    fn spawn_body_interactive_round_trips_and_defaults_false() {
222        let with: SpawnBody = serde_json::from_str(r#"{"prompt":"p","interactive":true}"#).unwrap();
223        assert!(spawn_request(with).interactive);
224        let without: SpawnBody = serde_json::from_str(r#"{"prompt":"p"}"#).unwrap();
225        assert!(!spawn_request(without).interactive);
226    }
227
228    #[test]
229    fn spawn_body_carries_frontmatter_path() {
230        let with: SpawnBody =
231            serde_json::from_str(r#"{"prompt":"p","frontmatter_path":"/tpl.md"}"#).unwrap();
232        assert_eq!(
233            spawn_request(with).frontmatter_path,
234            Some(std::path::PathBuf::from("/tpl.md"))
235        );
236        let without: SpawnBody = serde_json::from_str(r#"{"prompt":"p"}"#).unwrap();
237        assert_eq!(spawn_request(without).frontmatter_path, None);
238    }
239
240    #[test]
241    fn spawn_defaults_to_worktree_and_only_shared_opts_out() {
242        let default: SpawnBody = serde_json::from_str(r#"{"prompt":"p"}"#).unwrap();
243        assert!(spawn_request(default).isolation_worktree);
244        let shared: SpawnBody =
245            serde_json::from_str(r#"{"prompt":"p","isolation":"shared"}"#).unwrap();
246        assert!(!spawn_request(shared).isolation_worktree);
247    }
248
249    #[test]
250    fn workspace_summary_exposes_sources() {
251        let s = WorkspaceSummary {
252            name: "ws".into(),
253            root: "/ws".into(),
254            sources: vec![prospero_core::Source {
255                name: "a".into(),
256                path: "/ws/a".into(),
257            }],
258            health: prospero_core::WorkspaceHealth::Healthy,
259            agent_count: 0,
260            config: prospero_core::registry::RepoProviderConfig::default(),
261            source_specs: Vec::new(),
262            display_name: None,
263            providers: Vec::new(),
264            default_provider: None,
265            status: None,
266        };
267        let j = serde_json::to_value(&s).unwrap();
268        assert_eq!(j["sources"][0]["name"], "a");
269        // A local workspace has no CR specs, so the key must not appear at all.
270        assert!(
271            j.get("source_specs").is_none(),
272            "local payload gained a k8s key: {j}"
273        );
274    }
275
276    /// `sources` is the *discovered* view (name + path) — all a local checkout
277    /// has. A k8s workspace additionally carries the git remote and ref, and
278    /// the v2 config editor needs those to round-trip an edit: without them an
279    /// operator would face blank remote fields and have to retype every one
280    /// from memory. (#175)
281    #[test]
282    fn source_specs_carry_the_remote_and_ref_that_sources_loses() {
283        let s = WorkspaceSummary {
284            name: "ws".into(),
285            root: String::new(),
286            sources: vec![prospero_core::Source {
287                name: "caliban".into(),
288                path: "/work/caliban".into(),
289            }],
290            health: prospero_core::WorkspaceHealth::Healthy,
291            agent_count: 0,
292            config: prospero_core::registry::RepoProviderConfig::default(),
293            source_specs: vec![prospero_types::WorkspaceSourceSpec {
294                name: "caliban".into(),
295                repo: "git@github.com:caliban-ai/caliban.git".into(),
296                r#ref: Some("main".into()),
297                path: "/work/caliban".into(),
298            }],
299            display_name: None,
300            providers: Vec::new(),
301            default_provider: None,
302            status: None,
303        };
304        let j = serde_json::to_value(&s).unwrap();
305        assert_eq!(
306            j["source_specs"][0]["repo"],
307            "git@github.com:caliban-ai/caliban.git"
308        );
309        assert_eq!(j["source_specs"][0]["ref"], "main");
310        // And it round-trips back, which is what the dashboard depends on.
311        let back: WorkspaceSummary = serde_json::from_value(j).unwrap();
312        assert_eq!(
313            back.source_specs[0].repo,
314            "git@github.com:caliban-ai/caliban.git"
315        );
316    }
317}