1use 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#[derive(Debug, Deserialize)]
29pub struct FromSeq {
30 #[serde(default)]
32 pub from: u64,
33}
34
35pub 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#[derive(Debug, Default, Deserialize)]
61pub struct UsageQuery {
62 pub since: Option<String>,
64 pub until: Option<String>,
66 pub days: Option<i64>,
73}
74
75pub 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 #[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 assert!(
271 j.get("source_specs").is_none(),
272 "local payload gained a k8s key: {j}"
273 );
274 }
275
276 #[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 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}