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
154pub fn run_output(view: &RunView, now: OffsetDateTime) -> Value {
155 let mut value = json!(view);
156 let requests = value["requests"]
157 .as_array_mut()
158 .expect("serialized requests are an array");
159 for (output, request) in requests.iter_mut().zip(&view.requests) {
160 with_source(output, request);
161 }
162 let (summary, saved) = run_summary(view, now);
163 value["usage"] = json!({"spent":summary.usage.usage,"saved":saved});
164 value["summary"] = json!(summary);
165 value
166}
167
168pub fn profile_name(profile: &crate::config::StoredProfile, options: &ExecutionOptions) -> String {
171 if let Some(variant) = &options.variant {
172 return variant.to_string();
173 }
174 match profile.kind() {
175 crate::config::ProfileKind::Agent => [
176 options.backend.as_ref().map(|backend| backend.as_str()),
177 options.model.as_deref(),
178 options.reasoning.map(crate::config::Reasoning::as_str),
179 ]
180 .into_iter()
181 .flatten()
182 .collect::<Vec<_>>()
183 .join(" "),
184 kind => kind.name().to_owned(),
185 }
186}
187
188fn local_execution(request: &Request) -> bool {
189 request
190 .provenance
191 .as_ref()
192 .is_some_and(|source| source.request_id == request.id)
193}
194
195fn wall_time(
196 start: crate::types::Timestamp,
197 end: Option<crate::types::Timestamp>,
198 now: OffsetDateTime,
199) -> Option<Duration> {
200 let end = end.map_or(now, crate::types::Timestamp::time);
201 Some(
202 (end - start.time())
203 .max(time::Duration::ZERO)
204 .unsigned_abs(),
205 )
206}
207
208fn usage_totals<'a>(requests: impl IntoIterator<Item = &'a Request>) -> UsageTotals {
209 let (mut attempts, mut reported, mut unreported) = (0, 0, 0);
210 let mut totals = BTreeMap::<String, u64>::new();
211 for request in requests {
212 if let Some(entries) = request.usage.as_ref() {
213 if entries.is_empty() && request.started_at.is_some() {
214 unreported += 1;
215 }
216 for attempt in entries {
217 attempts += 1;
218 if add_usage(&mut totals, attempt) {
219 reported += 1;
220 } else {
221 unreported += 1;
222 }
223 }
224 } else if request.execution_id.is_some()
225 && request.profile.kind() != crate::config::ProfileKind::Agent
226 {
227 attempts += 1;
228 unreported += 1;
229 }
230 }
231 UsageTotals {
232 attempts,
233 usage_state: usage_state(reported, unreported),
234 reported_attempts: reported,
235 unreported_attempts: unreported,
236 usage: totals,
237 }
238}
239
240fn add_usage(totals: &mut BTreeMap<String, u64>, attempt: &crate::llm::Attempt) -> bool {
242 for (key, value) in attempt.usage.iter() {
243 let total = totals.entry(key.clone()).or_default();
244 *total = total.saturating_add(*value);
245 }
246 !attempt.usage.is_empty()
247}
248
249fn usage_state(reported: u64, unreported: u64) -> UsageState {
250 match (reported, unreported) {
251 (0, 0) => UsageState::None,
252 (0, _) => UsageState::Unreported,
253 (_, 0) => UsageState::Reported,
254 _ => UsageState::Partial,
255 }
256}
257
258pub fn request_summary(view: &RequestView, now: OffsetDateTime) -> RequestSummary<'_> {
259 let request = &view.request;
260 RequestSummary {
261 status: request.status(),
262 wall_time: wall_time(request.created_at, request.completed_at(), now),
263 executor_starts: u64::from(
264 local_execution(request)
265 && request.started_at.is_some()
266 && request.profile.kind() != crate::config::ProfileKind::Human,
267 ),
268 usage: usage_totals((!reused(request)).then_some(request)),
269 execution_source: request.provenance.as_ref(),
270 }
271}
272
273pub fn run_summary(view: &RunView, now: OffsetDateTime) -> (RunSummary, BTreeMap<String, u64>) {
274 let mut counts = BTreeMap::new();
275 let mut seen = BTreeSet::new();
276 let local = view.requests.iter().filter(|request| {
277 local_execution(request)
278 && request
279 .execution_id
280 .as_ref()
281 .is_some_and(|id| seen.insert(id))
282 });
283 let usage = usage_totals(local);
284 let (mut executed, mut reuses, mut saved) =
285 (Kinds::default(), Reuses::default(), BTreeMap::new());
286 for request in &view.requests {
287 *counts.entry(request.status()).or_default() += 1;
288 if reused(request) {
289 for attempt in request.reused_usage.iter().flatten() {
290 add_usage(&mut saved, attempt);
291 }
292 reuses.other_profile += u64::from(request.profile != request.requested_profile);
293 reuses.kinds.add(request.profile.kind());
294 } else if local_execution(request) {
295 executed.add(request.profile.kind());
296 }
297 }
298 let summary = RunSummary {
299 counts,
300 executed,
301 reused: reuses,
302 wall_time: wall_time(view.run.created_at, view.run.completed_at(), now),
303 executor_starts: view.run.executions_started,
304 usage,
305 };
306 (summary, saved)
307}
308
309impl std::fmt::Display for UsageState {
310 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
311 formatter.write_str(match self {
312 Self::None => "none",
313 Self::Unreported => "unreported",
314 Self::Reported => "reported",
315 Self::Partial => "partial",
316 })
317 }
318}
319
320#[cfg(test)]
321mod tests {
322 use super::*;
323 use serde_json::json;
324 fn view() -> RunView {
325 serde_json::from_value(json!({
326 "id":"run-legacy",
327 "repoPath":"repo",
328 "stateDir":"state",
329 "status":"RUNNING",
330 "createdAt":"2026-01-01T00:00:00Z",
331 "completedAt":null,
332 "selection":{"kind":"all"},
333 "validation":null,
334 "error":null,
335 "requests":[
336 {
337 "id":"run-legacy-1",
338 "runId":"run-legacy",
339 "evalId":"app/check",
340 "target":"app",
341 "title":"Check",
342 "profile":{"kind":"runtime","command":"fixture","args":[]},
343 "requestedProfile":{
344 "kind":"runtime",
345 "command":"fixture",
346 "args":[],
347 "timeoutMs":null,
348 },
349 "evalDefHash":"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
350 "executionId":"execution-source-1",
351 "provenance":{
352 "repoPath":"source",
353 "runId":"run-source",
354 "requestId":"run-source-1",
355 "evalId":"app/check",
356 "evalDefHash":"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
357 "completedAt":"2026-01-01T00:00:01Z",
358 },
359 "usage":null,
360 "reusedUsage":[
361 {
362 "turn":1,
363 "attempt":1,
364 "usage":{"inputTokens":17,"vendorDetail":{"cache":true}},
365 },
366 ],
367 "payload":{},
368 "references":{},
369 "deps":[],
370 "status":"GREEN",
371 "createdAt":"2026-01-01T00:00:00Z",
372 "startedAt":null,
373 "completedAt":"2026-01-01T00:00:01Z",
374 "cwd":"repo",
375 "runDir":null,
376 "argv":null,
377 "child":null,
378 "result":{"verdict":"GREEN"},
379 "error":null,
380 "errorCode":null,
381 "blockedReason":null,
382 },
383 ],
384 }))
385 .unwrap()
386 }
387 #[test]
388 fn supplied_time_is_deterministic_and_profile_omission_is_not_null() {
389 let now = "2026-01-01T00:00:05Z"
390 .parse::<crate::types::Timestamp>()
391 .unwrap()
392 .time();
393 let view = view();
394 let output = run_output(&view, now);
395 assert_eq!(output["summary"]["wallMs"], 5000);
396 assert_eq!(output["summary"]["reused"]["otherProfile"], 1);
397 assert_eq!(output["summary"]["reused"]["runtime"], 1);
398 assert_eq!(output["summary"]["attempts"], 0);
399 assert_eq!(output["usage"]["saved"], json!({"inputTokens":17}));
400 assert_eq!(run_output(&view, now), output);
401 assert_eq!(
402 wall_time("2026-01-01T00:00:06Z".parse().unwrap(), None, now),
403 Some(Duration::ZERO)
404 );
405 let request = RequestView {
406 request: view.requests[0].clone(),
407 claim: None,
408 execution: None,
409 definition: None,
410 };
411 assert_eq!(request_output(&request, now)["summary"]["wallMs"], 1000);
412 assert_eq!(
413 serde_json::to_value(&request.request.profile).unwrap(),
414 json!({"kind":"runtime","command":"fixture","args":[]})
415 );
416 assert_eq!(
417 serde_json::to_value(&request.request.requested_profile).unwrap(),
418 json!({"kind":"runtime","command":"fixture","args":[],"timeoutMs":null})
419 );
420 }
421}