1use std::{
2 collections::{BTreeMap, BTreeSet},
3 time::Duration,
4};
5
6use serde::Serialize;
7use serde_json::{Value, json};
8use time::OffsetDateTime;
9
10use crate::store::{ExecutionOptions, Request, RequestView, RunView};
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
14#[serde(rename_all = "lowercase")]
15pub enum SourceKind {
16 Cache,
18 Joined,
20 Remote,
22}
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
26#[serde(rename_all = "camelCase")]
27pub struct Source<'a> {
28 pub run_id: &'a crate::types::RunId,
29 pub request_id: &'a crate::types::RequestId,
30 pub kind: SourceKind,
31}
32
33pub fn source(request: &Request) -> Option<Source<'_>> {
37 let provenance = request
38 .provenance
39 .as_ref()
40 .filter(|source| source.request_id != request.id)?;
41 let kind = if request.origin.is_some() {
42 SourceKind::Remote
43 } else if request.joined {
44 SourceKind::Joined
45 } else {
46 SourceKind::Cache
47 };
48 Some(Source {
49 run_id: &provenance.run_id,
50 request_id: &provenance.request_id,
51 kind,
52 })
53}
54
55pub fn reused(request: &Request) -> bool {
57 source(request).is_some()
58}
59
60fn with_source(value: &mut Value, request: &Request) {
62 let object = value.as_object_mut().expect("a request is an object");
63 object.remove("joined");
64 object.insert("source".into(), json!(source(request)));
65 if let (Some(reference), Some(Value::Object(session))) =
67 (&request.session, object.get_mut("session"))
68 {
69 session.insert("ref".into(), json!(reference.to_string()));
70 }
71}
72
73#[derive(Debug, Default, Serialize)]
74#[serde(rename_all = "camelCase")]
75pub struct UsageTotals {
76 pub attempts: u64,
77 pub usage_state: UsageState,
78 pub reported_attempts: u64,
79 pub unreported_attempts: u64,
80 pub usage: BTreeMap<String, u64>,
81}
82#[derive(Debug, Default, Serialize)]
83#[serde(rename_all = "lowercase")]
84pub enum UsageState {
85 #[default]
86 None,
87 Unreported,
88 Reported,
89 Partial,
90}
91#[derive(Debug, Default, Serialize)]
92pub struct Kinds {
93 pub total: u64,
94 pub runtime: u64,
95 pub agent: u64,
96 pub human: u64,
97}
98impl Kinds {
99 fn add(&mut self, kind: crate::config::ProfileKind) {
100 self.total += 1;
101 match kind {
102 crate::config::ProfileKind::Runtime => self.runtime += 1,
103 crate::config::ProfileKind::Agent => self.agent += 1,
104 crate::config::ProfileKind::Human => self.human += 1,
105 }
106 }
107}
108#[derive(Debug, Default, Serialize)]
109#[serde(rename_all = "camelCase")]
110pub struct Reuses {
111 #[serde(flatten)]
112 pub kinds: Kinds,
113 pub other_profile: u64,
114}
115#[derive(Debug, Serialize)]
116#[serde(rename_all = "camelCase")]
117pub struct RequestSummary<'a> {
118 pub status: crate::types::RequestStatus,
119 #[serde(
120 rename = "wallMs",
121 serialize_with = "crate::config::validation::milliseconds::serialize"
122 )]
123 pub wall_time: Option<Duration>,
124 pub executor_starts: u64,
125 #[serde(flatten)]
126 pub usage: UsageTotals,
127 pub execution_source: Option<&'a crate::store::Provenance>,
128}
129#[derive(Debug, Serialize)]
130#[serde(rename_all = "camelCase")]
131pub struct RunSummary {
132 pub counts: BTreeMap<crate::types::RequestStatus, u64>,
133 pub executed: Kinds,
134 pub reused: Reuses,
135 #[serde(
136 rename = "wallMs",
137 serialize_with = "crate::config::validation::milliseconds::serialize"
138 )]
139 pub wall_time: Option<Duration>,
140 pub executor_starts: u64,
141 #[serde(flatten)]
142 pub usage: UsageTotals,
143}
144
145pub fn request_output(view: &RequestView, now: OffsetDateTime) -> Value {
146 let mut value = json!(view);
147 let request = &view.request;
148 with_source(&mut value, request);
149 let summary = request_summary(view, now);
150 value["summary"] = json!(summary);
151 value
152}
153
154#[derive(Serialize)]
158pub struct RunOutput<'a> {
159 #[serde(flatten)]
160 run: &'a crate::store::Run,
161 requests: RequestsOutput<'a>,
162 #[serde(skip_serializing_if = "<[_]>::is_empty")]
163 unreadable: &'a [crate::store::Unreadable],
164 usage: Usage,
165 summary: RunSummary,
166}
167
168#[derive(Serialize)]
170struct Usage {
171 spent: BTreeMap<String, u64>,
172 saved: BTreeMap<String, u64>,
173}
174
175struct RequestsOutput<'a>(&'a [std::sync::Arc<Request>]);
177impl Serialize for RequestsOutput<'_> {
178 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
179 use serde::ser::{Error, SerializeSeq};
180 let mut requests = serializer.serialize_seq(Some(self.0.len()))?;
181 for request in self.0 {
182 let mut value = serde_json::to_value(request).map_err(S::Error::custom)?;
183 with_source(&mut value, request);
184 requests.serialize_element(&value)?;
185 }
186 requests.end()
187 }
188}
189
190pub fn run_output(view: &RunView, now: OffsetDateTime) -> RunOutput<'_> {
191 let (summary, saved) = run_summary(view, now);
192 RunOutput {
193 run: &view.run,
194 requests: RequestsOutput(&view.requests),
195 unreadable: &view.unreadable,
196 usage: Usage {
197 spent: summary.usage.usage.clone(),
198 saved,
199 },
200 summary,
201 }
202}
203
204pub fn profile_name(profile: &crate::config::StoredProfile, options: &ExecutionOptions) -> String {
207 if let Some(variant) = &options.variant {
208 return variant.to_string();
209 }
210 match profile.kind() {
211 crate::config::ProfileKind::Agent => [
212 options.backend.as_ref().map(|backend| backend.as_str()),
213 options.model.as_deref(),
214 options.reasoning.map(crate::config::Reasoning::as_str),
215 ]
216 .into_iter()
217 .flatten()
218 .collect::<Vec<_>>()
219 .join(" "),
220 kind => kind.name().to_owned(),
221 }
222}
223
224fn local_execution(request: &Request) -> bool {
225 request
226 .provenance
227 .as_ref()
228 .is_some_and(|source| source.request_id == request.id)
229}
230
231fn wall_time(
232 start: crate::types::Timestamp,
233 end: Option<crate::types::Timestamp>,
234 now: OffsetDateTime,
235) -> Option<Duration> {
236 let end = end.map_or(now, crate::types::Timestamp::time);
237 Some(
238 (end - start.time())
239 .max(time::Duration::ZERO)
240 .unsigned_abs(),
241 )
242}
243
244fn usage_totals<'a>(requests: impl IntoIterator<Item = &'a Request>) -> UsageTotals {
245 let (mut attempts, mut reported, mut unreported) = (0, 0, 0);
246 let mut totals = BTreeMap::<String, u64>::new();
247 for request in requests {
248 if let Some(entries) = request.usage.as_ref() {
249 if entries.is_empty() && request.started_at.is_some() {
250 unreported += 1;
251 }
252 for attempt in entries {
253 attempts += 1;
254 if add_usage(&mut totals, attempt) {
255 reported += 1;
256 } else {
257 unreported += 1;
258 }
259 }
260 } else if request.execution_id.is_some()
261 && request.profile.kind() != crate::config::ProfileKind::Agent
262 {
263 attempts += 1;
264 unreported += 1;
265 }
266 }
267 UsageTotals {
268 attempts,
269 usage_state: usage_state(reported, unreported),
270 reported_attempts: reported,
271 unreported_attempts: unreported,
272 usage: totals,
273 }
274}
275
276fn add_usage(totals: &mut BTreeMap<String, u64>, attempt: &crate::llm::Attempt) -> bool {
278 for (key, value) in attempt.usage.iter() {
279 let total = totals.entry(key.clone()).or_default();
280 *total = total.saturating_add(*value);
281 }
282 !attempt.usage.is_empty()
283}
284
285fn usage_state(reported: u64, unreported: u64) -> UsageState {
286 match (reported, unreported) {
287 (0, 0) => UsageState::None,
288 (0, _) => UsageState::Unreported,
289 (_, 0) => UsageState::Reported,
290 _ => UsageState::Partial,
291 }
292}
293
294pub fn request_summary(view: &RequestView, now: OffsetDateTime) -> RequestSummary<'_> {
295 let request: &Request = &view.request;
296 RequestSummary {
297 status: request.status(),
298 wall_time: wall_time(request.created_at, request.completed_at(), now),
299 executor_starts: u64::from(
300 local_execution(request)
301 && request.started_at.is_some()
302 && request.profile.kind() != crate::config::ProfileKind::Human,
303 ),
304 usage: usage_totals((!reused(request)).then_some(request)),
305 execution_source: request.provenance.as_ref(),
306 }
307}
308
309pub fn run_summary(view: &RunView, now: OffsetDateTime) -> (RunSummary, BTreeMap<String, u64>) {
310 let mut counts = BTreeMap::new();
311 let mut seen = BTreeSet::new();
312 let local = view
313 .requests
314 .iter()
315 .map(|request| &**request)
316 .filter(|request| {
317 local_execution(request)
318 && request
319 .execution_id
320 .as_ref()
321 .is_some_and(|id| seen.insert(id))
322 });
323 let usage = usage_totals(local);
324 let (mut executed, mut reuses, mut saved) =
325 (Kinds::default(), Reuses::default(), BTreeMap::new());
326 for request in &view.requests {
327 *counts.entry(request.status()).or_default() += 1;
328 if reused(request) {
329 for attempt in request.reused_usage.iter().flatten() {
330 add_usage(&mut saved, attempt);
331 }
332 reuses.other_profile += u64::from(request.profile != request.requested_profile);
333 reuses.kinds.add(request.profile.kind());
334 } else if local_execution(request) {
335 executed.add(request.profile.kind());
336 }
337 }
338 let summary = RunSummary {
339 counts,
340 executed,
341 reused: reuses,
342 wall_time: wall_time(view.run.created_at, view.run.completed_at(), now),
343 executor_starts: view.run.executions_started,
344 usage,
345 };
346 (summary, saved)
347}
348
349impl std::fmt::Display for UsageState {
350 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
351 formatter.write_str(match self {
352 Self::None => "none",
353 Self::Unreported => "unreported",
354 Self::Reported => "reported",
355 Self::Partial => "partial",
356 })
357 }
358}
359
360#[cfg(test)]
361mod tests {
362 use super::*;
363 use serde_json::json;
364 fn view() -> RunView {
365 serde_json::from_value(json!({
366 "id":"run-legacy",
367 "repoPath":"repo",
368 "stateDir":"state",
369 "status":"RUNNING",
370 "createdAt":"2026-01-01T00:00:00Z",
371 "completedAt":null,
372 "selection":{"kind":"all"},
373 "validation":null,
374 "error":null,
375 "requests":[
376 {
377 "id":"run-legacy-1",
378 "runId":"run-legacy",
379 "evalId":"app/check",
380 "target":"app",
381 "title":"Check",
382 "profile":{"kind":"runtime","command":"fixture","args":[]},
383 "requestedProfile":{
384 "kind":"runtime",
385 "command":"fixture",
386 "args":[],
387 "timeoutMs":null,
388 },
389 "evalDefHash":"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
390 "executionId":"execution-source-1",
391 "provenance":{
392 "repoPath":"source",
393 "runId":"run-source",
394 "requestId":"run-source-1",
395 "evalId":"app/check",
396 "evalDefHash":"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
397 "completedAt":"2026-01-01T00:00:01Z",
398 },
399 "usage":null,
400 "reusedUsage":[
401 {
402 "turn":1,
403 "attempt":1,
404 "usage":{"inputTokens":17,"vendorDetail":{"cache":true}},
405 },
406 ],
407 "payload":{},
408 "references":{},
409 "deps":[],
410 "status":"GREEN",
411 "createdAt":"2026-01-01T00:00:00Z",
412 "startedAt":null,
413 "completedAt":"2026-01-01T00:00:01Z",
414 "cwd":"repo",
415 "runDir":null,
416 "argv":null,
417 "child":null,
418 "result":{"verdict":"GREEN"},
419 "error":null,
420 "errorCode":null,
421 "blockedReason":null,
422 },
423 ],
424 }))
425 .unwrap()
426 }
427 #[test]
428 fn supplied_time_is_deterministic_and_profile_omission_is_not_null() {
429 let now = "2026-01-01T00:00:05Z"
430 .parse::<crate::types::Timestamp>()
431 .unwrap()
432 .time();
433 let view = view();
434 let output = serde_json::to_value(run_output(&view, now)).unwrap();
435 assert_eq!(output["summary"]["wallMs"], 5000);
436 assert_eq!(output["summary"]["reused"]["otherProfile"], 1);
437 assert_eq!(output["summary"]["reused"]["runtime"], 1);
438 assert_eq!(output["summary"]["attempts"], 0);
439 assert_eq!(output["usage"]["saved"], json!({"inputTokens":17}));
440 assert_eq!(
441 serde_json::to_value(run_output(&view, now)).unwrap(),
442 output
443 );
444 assert_eq!(
445 wall_time("2026-01-01T00:00:06Z".parse().unwrap(), None, now),
446 Some(Duration::ZERO)
447 );
448 let request = RequestView {
449 request: view.requests[0].clone(),
450 claim: None,
451 execution: None,
452 definition: None,
453 };
454 assert_eq!(request_output(&request, now)["summary"]["wallMs"], 1000);
455 assert_eq!(
456 serde_json::to_value(&request.request.profile).unwrap(),
457 json!({"kind":"runtime","command":"fixture","args":[]})
458 );
459 assert_eq!(
460 serde_json::to_value(&request.request.requested_profile).unwrap(),
461 json!({"kind":"runtime","command":"fixture","args":[],"timeoutMs":null})
462 );
463 }
464}