use crate::config::{list_projects_tree, ProjectTreeInfo};
use crate::error::Result;
use crate::spec::{Spec, UserStory};
use crate::state::{
IterationStatus, LiveState, MachineState, RunState, RunStatus, SessionMetadata, StateManager,
};
use crate::worktree::MAIN_SESSION_ID;
use chrono::{DateTime, Utc};
use std::collections::HashSet;
use std::path::PathBuf;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Status {
Setup,
Running,
Reviewing,
Correcting,
Success,
Warning,
Error,
Idle,
}
impl Status {
pub fn from_machine_state(state: MachineState) -> Self {
match state {
MachineState::Initializing
| MachineState::PickingStory
| MachineState::LoadingSpec
| MachineState::GeneratingSpec => Status::Setup,
MachineState::RunningClaude => Status::Running,
MachineState::Reviewing => Status::Reviewing,
MachineState::Correcting => Status::Correcting,
MachineState::Committing | MachineState::CreatingPR | MachineState::Completed => {
Status::Success
}
MachineState::Failed => Status::Error,
MachineState::Idle => Status::Idle,
}
}
}
pub fn format_state_label(state: MachineState) -> &'static str {
match state {
MachineState::Idle => "Idle",
MachineState::LoadingSpec => "Loading Spec",
MachineState::GeneratingSpec => "Generating Spec",
MachineState::Initializing => "Initializing",
MachineState::PickingStory => "Picking Story",
MachineState::RunningClaude => "Running Claude",
MachineState::Reviewing => "Reviewing",
MachineState::Correcting => "Correcting",
MachineState::Committing => "Committing",
MachineState::CreatingPR => "Creating PR",
MachineState::Completed => "Completed",
MachineState::Failed => "Failed",
}
}
pub fn format_duration(started_at: DateTime<Utc>) -> String {
let now = Utc::now();
let duration = now.signed_duration_since(started_at);
format_duration_secs(duration.num_seconds().max(0) as u64)
}
pub fn format_run_duration(
started_at: DateTime<Utc>,
finished_at: Option<DateTime<Utc>>,
) -> String {
let end = finished_at.unwrap_or_else(Utc::now);
let duration = end.signed_duration_since(started_at);
format_duration_secs(duration.num_seconds().max(0) as u64)
}
pub fn format_duration_secs(total_secs: u64) -> String {
let hours = total_secs / 3600;
let minutes = (total_secs % 3600) / 60;
let seconds = total_secs % 60;
if hours > 0 {
format!("{}h {}m", hours, minutes)
} else if minutes > 0 {
format!("{}m {}s", minutes, seconds)
} else {
format!("{}s", seconds)
}
}
pub fn format_relative_time(timestamp: DateTime<Utc>) -> String {
let now = Utc::now();
let duration = now.signed_duration_since(timestamp);
format_relative_time_secs(duration.num_seconds().max(0) as u64)
}
pub fn format_relative_time_secs(total_secs: u64) -> String {
let minutes = total_secs / 60;
let hours = total_secs / 3600;
let days = total_secs / 86400;
if days > 0 {
format!("{}d ago", days)
} else if hours > 0 {
format!("{}h ago", hours)
} else if minutes > 0 {
format!("{}m ago", minutes)
} else {
"just now".to_string()
}
}
#[derive(Debug, Clone, Copy)]
pub struct RunProgress {
pub completed: usize,
pub total: usize,
}
impl RunProgress {
pub fn new(completed: usize, total: usize) -> Self {
Self { completed, total }
}
pub fn fraction(&self) -> f32 {
if self.total == 0 {
0.0
} else {
(self.completed as f32) / (self.total as f32)
}
}
pub fn as_fraction(&self) -> String {
let current = if self.completed < self.total {
self.completed + 1
} else {
self.total
};
format!("Story {}/{}", current, self.total)
}
pub fn as_story_fraction(&self) -> String {
self.as_fraction()
}
pub fn as_simple_fraction(&self) -> String {
format!("{}/{}", self.completed, self.total)
}
pub fn as_percentage(&self) -> String {
if self.total == 0 {
return "0%".to_string();
}
let pct = (self.completed * 100) / self.total;
format!("{}%", pct)
}
}
#[derive(Debug, Clone)]
pub struct ProjectData {
pub info: ProjectTreeInfo,
pub active_run: Option<RunState>,
pub progress: Option<RunProgress>,
pub load_error: Option<String>,
}
#[derive(Debug, Clone)]
pub struct SessionData {
pub project_name: String,
pub metadata: SessionMetadata,
pub run: Option<RunState>,
pub progress: Option<RunProgress>,
pub load_error: Option<String>,
pub is_main_session: bool,
pub is_stale: bool,
pub live_output: Option<LiveState>,
pub cached_user_stories: Option<Vec<UserStory>>,
}
impl SessionData {
pub fn display_title(&self) -> String {
if self.is_main_session {
format!("{} (main)", self.project_name)
} else {
format!("{} ({})", self.project_name, &self.metadata.session_id)
}
}
pub fn has_fresh_heartbeat(&self) -> bool {
self.live_output
.as_ref()
.map(|live| live.is_heartbeat_fresh())
.unwrap_or(false)
}
pub fn is_actively_running(&self) -> bool {
if self.is_stale || !self.metadata.is_running {
return false;
}
self.live_output
.as_ref()
.map(|live| live.is_heartbeat_fresh())
.unwrap_or(true) }
pub fn appears_stuck(&self) -> bool {
if !self.metadata.is_running || self.is_stale {
return false;
}
self.live_output
.as_ref()
.map(|live| !live.is_heartbeat_fresh())
.unwrap_or(false) }
pub fn truncated_worktree_path(&self) -> String {
let path = &self.metadata.worktree_path;
let components: Vec<_> = path.components().collect();
if components.len() <= 2 {
path.display().to_string()
} else {
let last_two: PathBuf = components[components.len() - 2..].iter().collect();
format!(".../{}", last_two.display())
}
}
}
#[derive(Debug, Clone)]
pub struct RunHistoryEntry {
pub project_name: String,
pub run_id: String,
pub started_at: chrono::DateTime<chrono::Utc>,
pub finished_at: Option<chrono::DateTime<chrono::Utc>>,
pub status: RunStatus,
pub completed_stories: usize,
pub total_stories: usize,
pub branch: String,
}
impl RunHistoryEntry {
pub fn new(
project_name: String,
run: &RunState,
completed_stories: usize,
total_stories: usize,
) -> Self {
Self {
project_name,
run_id: run.run_id.clone(),
started_at: run.started_at,
finished_at: run.finished_at,
status: run.status,
completed_stories,
total_stories,
branch: run.branch.clone(),
}
}
pub fn from_run_state(project_name: String, run: &RunState) -> Self {
let completed_stories = run
.iterations
.iter()
.filter(|i| i.status == IterationStatus::Success)
.map(|i| &i.story_id)
.collect::<HashSet<_>>()
.len();
let story_ids: HashSet<_> = run.iterations.iter().map(|i| &i.story_id).collect();
let total_stories = story_ids.len().max(1);
Self {
project_name,
run_id: run.run_id.clone(),
started_at: run.started_at,
finished_at: run.finished_at,
status: run.status,
completed_stories,
total_stories,
branch: run.branch.clone(),
}
}
pub fn story_count_text(&self) -> String {
format!("{}/{} stories", self.completed_stories, self.total_stories)
}
pub fn status_text(&self) -> &'static str {
match self.status {
RunStatus::Completed => "Completed",
RunStatus::Failed => "Failed",
RunStatus::Running => "Running",
RunStatus::Interrupted => "Interrupted",
}
}
}
#[derive(Debug, Clone, Default)]
pub struct UiData {
pub projects: Vec<ProjectData>,
pub sessions: Vec<SessionData>,
pub has_active_runs: bool,
}
#[derive(Debug, Clone, Default)]
pub struct RunHistoryOptions {
pub project_filter: Option<String>,
pub max_entries: Option<usize>,
}
#[derive(Debug, Clone, Default)]
pub struct RunHistoryData {
pub entries: Vec<RunHistoryEntry>,
pub run_states: std::collections::HashMap<String, RunState>,
}
pub fn load_ui_data(project_filter: Option<&str>) -> Result<UiData> {
let sessions = load_sessions(project_filter);
let has_active_runs = !sessions.is_empty();
let projects = match list_projects_tree() {
Ok(tree_infos) => {
let filtered: Vec<_> = if let Some(filter) = project_filter {
tree_infos
.into_iter()
.filter(|p| p.name == filter)
.collect()
} else {
tree_infos
};
filtered.iter().map(load_project_data).collect()
}
Err(_) => {
Vec::new()
}
};
Ok(UiData {
projects,
sessions,
has_active_runs,
})
}
fn load_project_data(info: &ProjectTreeInfo) -> ProjectData {
let (active_run, load_error) = if info.has_active_run {
match StateManager::for_project(&info.name) {
Ok(sm) => match sm.load_current() {
Ok(run) => (run, None),
Err(e) => (None, Some(format!("Corrupted state: {}", e))),
},
Err(e) => (None, Some(format!("State error: {}", e))),
}
} else {
(None, None)
};
let progress = active_run.as_ref().and_then(|run| {
Spec::load(&run.spec_json_path)
.ok()
.map(|spec| RunProgress {
completed: spec.completed_count(),
total: spec.total_count(),
})
});
ProjectData {
info: info.clone(),
active_run,
progress,
load_error,
}
}
fn load_sessions(project_filter: Option<&str>) -> Vec<SessionData> {
let mut sessions: Vec<SessionData> = Vec::new();
let base_dir = match crate::config::config_dir() {
Ok(dir) => dir,
Err(_) => return sessions,
};
if !base_dir.exists() {
return sessions;
}
let project_dirs = match std::fs::read_dir(&base_dir) {
Ok(entries) => entries,
Err(_) => return sessions,
};
for entry in project_dirs.filter_map(|e| e.ok()) {
let project_path = entry.path();
if !project_path.is_dir() {
continue;
}
let project_name = match project_path.file_name().and_then(|n| n.to_str()) {
Some(name) => name.to_string(),
None => continue,
};
if let Some(filter) = project_filter {
if project_name != filter {
continue;
}
}
let sessions_dir = project_path.join("sessions");
if !sessions_dir.exists() {
continue;
}
let session_dirs = match std::fs::read_dir(&sessions_dir) {
Ok(entries) => entries,
Err(_) => continue,
};
for session_entry in session_dirs.filter_map(|e| e.ok()) {
let session_path = session_entry.path();
if !session_path.is_dir() {
continue;
}
let metadata_path = session_path.join("metadata.json");
let metadata: SessionMetadata = match std::fs::read_to_string(&metadata_path) {
Ok(content) => match serde_json::from_str(&content) {
Ok(m) => m,
Err(_) => continue, },
Err(_) => continue, };
if !metadata.is_running {
continue;
}
let is_stale = !metadata.worktree_path.exists();
let is_main_session = metadata.session_id == MAIN_SESSION_ID;
if is_stale {
sessions.push(SessionData {
project_name: project_name.clone(),
metadata,
run: None,
progress: None,
load_error: Some("Worktree has been deleted".to_string()),
is_main_session,
is_stale: true,
live_output: None,
cached_user_stories: None,
});
continue;
}
let state_path = session_path.join("state.json");
let (run, load_error): (Option<RunState>, Option<String>) =
match std::fs::read_to_string(&state_path) {
Ok(content) => match serde_json::from_str(&content) {
Ok(state) => (Some(state), None),
Err(e) => (None, Some(format!("Corrupted state: {}", e))),
},
Err(_) => (None, Some("State file not found".to_string())),
};
let live_path = session_path.join("live.json");
let live_output: Option<LiveState> = std::fs::read_to_string(&live_path)
.ok()
.and_then(|content| serde_json::from_str(&content).ok());
let (progress, cached_user_stories) = run
.as_ref()
.and_then(|r| Spec::load(&r.spec_json_path).ok())
.map(|spec| {
let progress = RunProgress {
completed: spec.completed_count(),
total: spec.total_count(),
};
(Some(progress), Some(spec.user_stories))
})
.unwrap_or((None, None));
sessions.push(SessionData {
project_name: project_name.clone(),
metadata,
run,
progress,
load_error,
is_main_session,
is_stale: false,
live_output,
cached_user_stories,
});
}
}
sessions.sort_by(|a, b| b.metadata.last_active_at.cmp(&a.metadata.last_active_at));
sessions
}
pub fn load_session_by_id(project_name: &str, session_id: &str) -> Option<SessionData> {
let base_dir = crate::config::config_dir().ok()?;
let session_path = base_dir
.join(project_name)
.join("sessions")
.join(session_id);
if !session_path.is_dir() {
return None;
}
let metadata_path = session_path.join("metadata.json");
let metadata: SessionMetadata = std::fs::read_to_string(&metadata_path)
.ok()
.and_then(|content| serde_json::from_str(&content).ok())?;
let is_stale = !metadata.worktree_path.exists();
let is_main_session = metadata.session_id == MAIN_SESSION_ID;
let state_path = session_path.join("state.json");
let (run, load_error): (Option<RunState>, Option<String>) =
match std::fs::read_to_string(&state_path) {
Ok(content) => match serde_json::from_str(&content) {
Ok(state) => (Some(state), None),
Err(e) => (None, Some(format!("Corrupted state: {}", e))),
},
Err(_) => (None, Some("State file not found".to_string())),
};
let live_path = session_path.join("live.json");
let live_output: Option<LiveState> = std::fs::read_to_string(&live_path)
.ok()
.and_then(|content| serde_json::from_str(&content).ok());
let (progress, cached_user_stories) = run
.as_ref()
.and_then(|r| Spec::load(&r.spec_json_path).ok())
.map(|spec| {
let progress = RunProgress {
completed: spec.completed_count(),
total: spec.total_count(),
};
(Some(progress), Some(spec.user_stories))
})
.unwrap_or((None, None));
Some(SessionData {
project_name: project_name.to_string(),
metadata,
run,
progress,
load_error,
is_main_session,
is_stale,
live_output,
cached_user_stories,
})
}
pub fn load_archived_run(project_name: &str, run_id: &str) -> Option<RunState> {
let sm = StateManager::for_project(project_name).ok()?;
let archived = sm.list_archived().ok()?;
archived.into_iter().find(|r| r.run_id == run_id)
}
pub fn load_run_history(
projects: &[ProjectData],
options: &RunHistoryOptions,
include_full_state: bool,
) -> Result<RunHistoryData> {
let mut history: Vec<RunHistoryEntry> = Vec::new();
let mut run_states: std::collections::HashMap<String, RunState> =
std::collections::HashMap::new();
let project_names: Vec<String> = if let Some(ref filter) = options.project_filter {
vec![filter.clone()]
} else {
projects.iter().map(|p| p.info.name.clone()).collect()
};
for project_name in project_names {
if let Ok(sm) = StateManager::for_project(&project_name) {
if let Ok(archived) = sm.list_archived() {
for run in archived {
let (completed, total) = Spec::load(&run.spec_json_path)
.map(|spec| (spec.completed_count(), spec.total_count()))
.unwrap_or_else(|_| {
let completed = run
.iterations
.iter()
.filter(|i| i.status == IterationStatus::Success)
.count();
(completed, run.iterations.len().max(completed))
});
if include_full_state {
run_states.insert(run.run_id.clone(), run.clone());
}
history.push(RunHistoryEntry::new(
project_name.clone(),
&run,
completed,
total,
));
}
}
}
}
history.sort_by(|a, b| b.started_at.cmp(&a.started_at));
if let Some(max) = options.max_entries {
history.truncate(max);
}
Ok(RunHistoryData {
entries: history,
run_states,
})
}
pub fn load_project_run_history(project_name: &str) -> Result<Vec<RunHistoryEntry>> {
let mut history: Vec<RunHistoryEntry> = Vec::new();
let sm = StateManager::for_project(project_name)?;
let archived = sm.list_archived()?;
for run in archived {
history.push(RunHistoryEntry::from_run_state(
project_name.to_string(),
&run,
));
}
history.sort_by(|a, b| {
let a_running = matches!(a.status, RunStatus::Running);
let b_running = matches!(b.status, RunStatus::Running);
match (a_running, b_running) {
(true, false) => std::cmp::Ordering::Less,
(false, true) => std::cmp::Ordering::Greater,
_ => b.started_at.cmp(&a.started_at),
}
});
Ok(history)
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Utc;
use std::path::PathBuf;
#[test]
fn test_run_progress_formatting() {
assert_eq!(RunProgress::new(1, 5).as_fraction(), "Story 2/5");
assert_eq!(RunProgress::new(0, 5).as_fraction(), "Story 1/5");
assert_eq!(RunProgress::new(5, 5).as_fraction(), "Story 5/5"); assert_eq!(RunProgress::new(0, 0).as_fraction(), "Story 0/0");
assert_eq!(RunProgress::new(2, 5).as_percentage(), "40%");
assert_eq!(RunProgress::new(5, 5).as_percentage(), "100%");
assert_eq!(RunProgress::new(0, 0).as_percentage(), "0%");
assert!((RunProgress::new(2, 5).fraction() - 0.4).abs() < 0.001);
assert_eq!(RunProgress::new(0, 0).fraction(), 0.0);
assert_eq!(RunProgress::new(2, 5).as_simple_fraction(), "2/5");
}
fn make_test_session(is_main: bool, is_running: bool, is_stale: bool) -> SessionData {
SessionData {
project_name: "test-project".to_string(),
metadata: SessionMetadata {
session_id: if is_main { "main" } else { "abc123" }.to_string(),
worktree_path: PathBuf::from("/path/to/repo"),
branch_name: "test-branch".to_string(),
created_at: Utc::now(),
last_active_at: Utc::now(),
is_running,
spec_json_path: None,
},
run: None,
progress: None,
load_error: None,
is_main_session: is_main,
is_stale,
live_output: None,
cached_user_stories: None,
}
}
#[test]
fn test_session_data_display_and_paths() {
let main = make_test_session(true, false, false);
assert_eq!(main.display_title(), "test-project (main)");
let worktree = make_test_session(false, false, false);
assert_eq!(worktree.display_title(), "test-project (abc123)");
let mut short_path = make_test_session(false, false, false);
short_path.metadata.worktree_path = PathBuf::from("repo");
assert_eq!(short_path.truncated_worktree_path(), "repo");
let mut long_path = make_test_session(false, false, false);
long_path.metadata.worktree_path = PathBuf::from("/home/user/projects/repo");
assert_eq!(long_path.truncated_worktree_path(), ".../projects/repo");
}
#[test]
fn test_session_heartbeat_and_status() {
let no_live = make_test_session(true, true, false);
assert!(!no_live.has_fresh_heartbeat());
assert!(no_live.is_actively_running());
let mut fresh = make_test_session(true, true, false);
fresh.live_output = Some(LiveState::new(MachineState::RunningClaude));
assert!(fresh.has_fresh_heartbeat());
assert!(!fresh.appears_stuck());
let stale = make_test_session(false, true, true);
assert!(!stale.is_actively_running());
let mut stuck = make_test_session(true, true, false);
let mut stale_live = LiveState::new(MachineState::RunningClaude);
stale_live.last_heartbeat = Utc::now() - chrono::Duration::seconds(65);
stuck.live_output = Some(stale_live);
assert!(stuck.appears_stuck());
let not_running = make_test_session(true, false, false);
assert!(!not_running.appears_stuck());
}
#[test]
fn test_status_from_machine_state() {
assert_eq!(
Status::from_machine_state(MachineState::Initializing),
Status::Setup
);
assert_eq!(
Status::from_machine_state(MachineState::PickingStory),
Status::Setup
);
assert_eq!(
Status::from_machine_state(MachineState::LoadingSpec),
Status::Setup
);
assert_eq!(
Status::from_machine_state(MachineState::RunningClaude),
Status::Running
);
assert_eq!(
Status::from_machine_state(MachineState::Reviewing),
Status::Reviewing
);
assert_eq!(
Status::from_machine_state(MachineState::Correcting),
Status::Correcting
);
assert_eq!(
Status::from_machine_state(MachineState::Committing),
Status::Success
);
assert_eq!(
Status::from_machine_state(MachineState::Completed),
Status::Success
);
assert_eq!(
Status::from_machine_state(MachineState::Failed),
Status::Error
);
assert_eq!(Status::from_machine_state(MachineState::Idle), Status::Idle);
}
#[test]
fn test_duration_formatting() {
assert_eq!(format_duration_secs(30), "30s");
assert_eq!(format_duration_secs(125), "2m 5s");
assert_eq!(format_duration_secs(3600), "1h 0m");
assert_eq!(format_duration_secs(7265), "2h 1m");
}
#[test]
fn test_format_run_duration_with_finished_at() {
let started = Utc::now() - chrono::Duration::seconds(300);
let finished = started + chrono::Duration::seconds(125);
assert_eq!(format_run_duration(started, Some(finished)), "2m 5s");
}
#[test]
fn test_format_run_duration_without_finished_at() {
let started = Utc::now() - chrono::Duration::seconds(5);
let result = format_run_duration(started, None);
assert!(
result.ends_with('s'),
"Expected seconds format, got: {}",
result
);
}
#[test]
fn test_relative_time_formatting() {
assert_eq!(format_relative_time_secs(30), "just now");
assert_eq!(format_relative_time_secs(300), "5m ago");
assert_eq!(format_relative_time_secs(3600), "1h ago");
assert_eq!(format_relative_time_secs(86400), "1d ago");
}
#[test]
fn test_run_history_entry() {
let entry = RunHistoryEntry {
project_name: "test-project".to_string(),
run_id: "test-run".to_string(),
started_at: Utc::now(),
finished_at: None,
status: RunStatus::Completed,
completed_stories: 3,
total_stories: 5,
branch: "feature/test".to_string(),
};
assert_eq!(entry.status_text(), "Completed");
assert_eq!(entry.story_count_text(), "3/5 stories");
}
fn make_history_entry(run_id: &str, status: RunStatus, age_secs: i64) -> RunHistoryEntry {
RunHistoryEntry {
project_name: "test".to_string(),
run_id: run_id.to_string(),
started_at: Utc::now() - chrono::Duration::seconds(age_secs),
finished_at: None,
status,
completed_stories: 0,
total_stories: 5,
branch: "test".to_string(),
}
}
#[test]
fn test_run_history_sorting() {
let mut history = vec![
make_history_entry("completed-old", RunStatus::Completed, 60),
make_history_entry("running", RunStatus::Running, 3600),
make_history_entry("completed-new", RunStatus::Completed, 0),
];
history.sort_by(|a, b| {
let a_running = matches!(a.status, RunStatus::Running);
let b_running = matches!(b.status, RunStatus::Running);
match (a_running, b_running) {
(true, false) => std::cmp::Ordering::Less,
(false, true) => std::cmp::Ordering::Greater,
_ => b.started_at.cmp(&a.started_at),
}
});
assert_eq!(history[0].run_id, "running");
assert_eq!(history[1].run_id, "completed-new");
assert_eq!(history[2].run_id, "completed-old");
}
}