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 FleetFrom {
30 #[serde(default)]
32 pub from: Option<String>,
33}
34
35#[derive(Debug, Deserialize)]
40pub struct FromSeq {
41 #[serde(default)]
43 pub from: u64,
44}
45
46pub 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#[derive(Debug, Default, Deserialize)]
74pub struct UsageQuery {
75 pub since: Option<String>,
77 pub until: Option<String>,
79 pub days: Option<i64>,
86}
87
88pub const DEFAULT_USAGE_WINDOW_DAYS: i64 = 7;
90
91pub 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 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
140pub 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 #[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 assert!(
339 j.get("source_specs").is_none(),
340 "local payload gained a k8s key: {j}"
341 );
342 }
343
344 #[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 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}