use crate::orchestrator::dag::Dag;
use crate::orchestrator::job::JobState;
use serde::{Deserialize, Serialize};
pub type RunId = String;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RunState {
Created,
Planning,
Executing,
Waiting,
Aggregating,
Completed,
Failed,
Cancelled,
}
impl RunState {
pub fn is_terminal(self) -> bool {
matches!(
self,
RunState::Completed | RunState::Failed | RunState::Cancelled
)
}
pub fn label(self) -> &'static str {
match self {
RunState::Created => "created",
RunState::Planning => "planning",
RunState::Executing => "executing",
RunState::Waiting => "waiting",
RunState::Aggregating => "aggregating",
RunState::Completed => "completed",
RunState::Failed => "failed",
RunState::Cancelled => "cancelled",
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct RunBudget {
pub max_total_tokens: Option<u64>,
}
impl RunBudget {
pub fn unlimited() -> Self {
Self::default()
}
pub fn max_total_tokens(n: u64) -> Self {
Self {
max_total_tokens: Some(n),
}
}
pub fn exceeded(&self, spent: u64) -> bool {
matches!(self.max_total_tokens, Some(m) if spent > m)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Run {
pub id: RunId,
pub goal: String,
pub state: RunState,
pub budget: RunBudget,
pub dag: Dag,
pub spent_tokens: u64,
}
impl Run {
pub fn new(id: impl Into<RunId>, goal: impl Into<String>, dag: Dag) -> Self {
Self {
id: id.into(),
goal: goal.into(),
state: RunState::Created,
budget: RunBudget::unlimited(),
dag,
spent_tokens: 0,
}
}
pub fn with_budget(mut self, b: RunBudget) -> Self {
self.budget = b;
self
}
}
#[derive(Debug, Clone)]
pub struct RunReport {
pub run_id: RunId,
pub goal: String,
pub state: RunState,
pub spent_tokens: u64,
pub jobs: Vec<JobReport>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JobReport {
pub id: String,
pub state: JobState,
pub result: Option<String>,
pub error: Option<String>,
pub attempts: u32,
pub visits: u32,
}
impl RunReport {
pub fn succeeded(&self) -> usize {
self.jobs
.iter()
.filter(|j| j.state == JobState::Succeeded)
.count()
}
pub fn dead_lettered(&self) -> usize {
self.jobs
.iter()
.filter(|j| j.state == JobState::DeadLettered)
.count()
}
pub fn render(&self) -> String {
let mut s = format!(
"Run `{}` [{}] — {} goal: {}\n",
self.run_id,
self.state.label(),
format_args!("{} jobs, {} tokens;", self.jobs.len(), self.spent_tokens),
self.goal,
);
for j in &self.jobs {
s.push_str(&format!(" - {}: {}", j.id, j.state.label()));
if j.visits > 1 {
s.push_str(&format!(" (entered {}×)", j.visits));
}
let brief = |t: &str| t.trim().chars().take(80).collect::<String>();
if let Some(t) = &j.result {
s.push_str(&format!(" — {}", brief(t)));
}
if let Some(e) = &j.error {
s.push_str(&format!(" — failed: {}", brief(e)));
}
s.push('\n');
}
s
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::orchestrator::job::JobState;
#[test]
fn render_truncates_on_char_boundary_with_multibyte_text() {
let long = format!("🔄 {}", "更".repeat(100));
let report = RunReport {
run_id: "r".into(),
goal: "g".into(),
state: RunState::Completed,
spent_tokens: 0,
jobs: vec![JobReport {
id: "j".into(),
state: JobState::Succeeded,
result: Some(long),
error: None,
attempts: 1,
visits: 1,
}],
};
let out = report.render(); assert!(out.contains("j: succeeded"));
}
}