use anyhow::Result;
use gflow::{client::Client, core::job::JobState, tmux::get_all_session_names};
mod display;
mod output;
mod tree;
use display::{display_grouped_jobs, display_jobs_table};
use output::{output_csv, output_json, output_yaml, OutputFormat};
#[cfg(test)]
use std::collections::HashSet;
use tree::display_jobs_tree;
#[cfg(test)]
use tree::{build_dependency_tree, JobNodeChild};
pub struct ListOptions {
pub user: Option<String>,
pub states: Option<String>,
pub jobs: Option<String>,
pub names: Option<String>,
pub project: Option<String>,
pub sort: String,
pub limit: i32,
pub all: bool,
pub completed: bool,
pub since: Option<String>,
pub group: bool,
pub tree: bool,
pub format: Option<String>,
pub tmux: bool,
pub output: String,
pub watch: bool,
pub interval: u64,
}
pub async fn handle_list(client: &Client, options: ListOptions) -> Result<()> {
if options.watch {
let interval = std::time::Duration::from_secs(options.interval);
loop {
print!("\x1B[2J\x1B[H");
let now = chrono::Local::now();
println!(
"Last updated: {} [Refreshing every {}s. Press Ctrl+C to exit]\n",
now.format("%Y-%m-%d %H:%M:%S"),
options.interval
);
display_once(client, &options).await?;
tokio::time::sleep(interval).await;
}
} else {
display_once(client, &options).await
}
}
async fn display_once(client: &Client, options: &ListOptions) -> Result<()> {
let current_user = gflow::platform::get_current_username();
let user_filter = match options.user.as_deref().map(str::trim) {
None => Some(current_user.clone()),
Some("") => Some(current_user.clone()),
Some("all") | Some("*") => None,
Some(u) => Some(u.to_string()),
};
let states_filter = if options.completed {
Some(
JobState::completed_states()
.iter()
.map(|s| s.to_string())
.collect::<Vec<_>>()
.join(","),
)
} else if let Some(ref states) = options.states {
Some(states.clone())
} else if options.all {
None
} else {
Some(
JobState::active_states()
.iter()
.map(|s| s.to_string())
.collect::<Vec<_>>()
.join(","),
)
};
let created_after = if let Some(ref since_str) = options.since {
Some(gflow::utils::parse_since_time(since_str)?)
} else {
None
};
let mut jobs_vec = client
.list_jobs_with_query(states_filter, user_filter, None, None, created_after)
.await?;
if let Some(job_ids) = options.jobs.as_deref() {
let job_ids_vec: Vec<u32> = job_ids
.split(',')
.filter_map(|s| s.trim().parse().ok())
.collect();
if !job_ids_vec.is_empty() {
jobs_vec.retain(|job| job_ids_vec.contains(&job.id));
}
}
if let Some(names_filter) = options.names.as_deref() {
let names_vec: Vec<String> = names_filter
.split(',')
.map(|s| s.trim().to_string())
.collect();
if !names_vec.is_empty() {
jobs_vec.retain(|job| {
job.run_name
.as_ref()
.is_some_and(|run_name| names_vec.iter().any(|n| n == run_name.as_str()))
});
}
}
if let Some(project_filter) = options.project.as_deref() {
let project = project_filter.trim();
if !project.is_empty() {
jobs_vec.retain(|job| job.project.as_ref().is_some_and(|p| p.as_str() == project));
}
}
let tmux_sessions = get_all_session_names();
if options.tmux {
jobs_vec.retain(|job| {
job.run_name
.as_ref()
.is_some_and(|run_name| tmux_sessions.contains(run_name.as_str()))
});
}
if jobs_vec.is_empty() {
println!("No jobs found.");
return Ok(());
}
sort_jobs(&mut jobs_vec, &options.sort);
let output_format: OutputFormat = options.output.parse().map_err(|_| {
anyhow::anyhow!(
"Invalid output format '{}'. Valid options: table, json, csv, yaml",
options.output
)
})?;
let effective_limit = if options.all { 0 } else { options.limit };
let mut limit_message = None;
if effective_limit != 0 {
let total_jobs = jobs_vec.len();
if effective_limit > 0 {
let limit_usize = effective_limit as usize;
if jobs_vec.len() > limit_usize {
jobs_vec.truncate(limit_usize);
limit_message = Some(format!(
"Showing first {} of {} jobs (use --all or -n 0 to show all)",
effective_limit, total_jobs
));
}
} else {
let limit_usize = (-effective_limit) as usize;
if jobs_vec.len() > limit_usize {
let start = jobs_vec.len() - limit_usize;
jobs_vec = jobs_vec.into_iter().skip(start).collect();
limit_message = Some(format!(
"Showing last {} of {} jobs (use --all or -n 0 to show all)",
limit_usize, total_jobs
));
}
}
}
if let Some(msg) = limit_message {
if output_format == OutputFormat::Table {
println!("{}", msg);
println!();
}
}
match output_format {
OutputFormat::Table => {
if options.group {
display_grouped_jobs(&jobs_vec, options.format.as_deref(), &tmux_sessions);
} else if options.tree {
display_jobs_tree(&jobs_vec, options.format.as_deref(), &tmux_sessions);
} else {
display_jobs_table(&jobs_vec, options.format.as_deref(), &tmux_sessions);
}
}
OutputFormat::Json => output_json(&jobs_vec)?,
OutputFormat::Csv => output_csv(&jobs_vec)?,
OutputFormat::Yaml => output_yaml(&jobs_vec)?,
}
Ok(())
}
fn sort_jobs(jobs: &mut [gflow::core::job::Job], sort_field: &str) {
match sort_field.to_lowercase().as_str() {
"id" => jobs.sort_by_key(|j| j.id),
"state" => jobs.sort_by_key(|j| j.state),
"time" => jobs.sort_by_key(|a| a.started_at),
"name" => jobs.sort_by(|a, b| {
a.run_name
.as_deref()
.unwrap_or("")
.cmp(b.run_name.as_deref().unwrap_or(""))
}),
"gpus" | "nodes" => jobs.sort_by_key(|j| j.gpus),
"priority" => jobs.sort_by_key(|j| j.priority),
_ => {
eprintln!(
"Warning: Unknown sort field '{}', using default 'id'",
sort_field
);
jobs.sort_by_key(|j| j.id);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use gflow::core::job::{GpuSharingMode, Job, JobState};
use std::path::PathBuf;
fn create_test_job(id: u32, name: &str, depends_on: Option<u32>) -> Job {
Job {
id,
script: None,
command: Some(format!("test command {}", id).into()),
gpus: 1,
conda_env: None,
run_dir: PathBuf::from("/tmp"),
priority: 10,
depends_on,
depends_on_ids: smallvec::smallvec![],
dependency_mode: None,
auto_cancel_on_dependency_failure: true,
task_id: None,
gpu_sharing_mode: GpuSharingMode::Exclusive,
run_name: Some(name.into()),
project: None,
state: JobState::Finished,
gpu_ids: Some(smallvec::smallvec![0]),
submitted_at: None,
started_at: None,
finished_at: None,
time_limit: None,
memory_limit_mb: None,
gpu_memory_limit_mb: None,
submitted_by: "testuser".into(),
redone_from: None,
auto_close_tmux: false,
parameters: gflow::core::job::Parameters::new(),
group_id: None,
max_concurrent: None,
reason: None,
}
}
fn create_test_job_with_state(id: u32, name: &str, state: JobState) -> Job {
Job {
id,
script: None,
command: Some(format!("test command {}", id).into()),
gpus: 1,
conda_env: None,
run_dir: PathBuf::from("/tmp"),
priority: 10,
depends_on: None,
depends_on_ids: smallvec::smallvec![],
dependency_mode: None,
auto_cancel_on_dependency_failure: true,
task_id: None,
gpu_sharing_mode: GpuSharingMode::Exclusive,
run_name: Some(name.into()),
project: None,
state,
gpu_ids: Some(smallvec::smallvec![0]),
submitted_at: None,
started_at: None,
finished_at: None,
time_limit: None,
memory_limit_mb: None,
gpu_memory_limit_mb: None,
submitted_by: "testuser".into(),
redone_from: None,
auto_close_tmux: false,
parameters: gflow::core::job::Parameters::new(),
group_id: None,
max_concurrent: None,
reason: None,
}
}
fn create_test_job_with_redo(id: u32, name: &str, redone_from: Option<u32>) -> Job {
Job {
id,
script: None,
command: Some(format!("test command {}", id).into()),
gpus: 1,
conda_env: None,
run_dir: PathBuf::from("/tmp"),
priority: 10,
depends_on: None,
depends_on_ids: smallvec::smallvec![],
dependency_mode: None,
auto_cancel_on_dependency_failure: true,
task_id: None,
gpu_sharing_mode: GpuSharingMode::Exclusive,
run_name: Some(name.into()),
project: None,
state: JobState::Finished,
gpu_ids: Some(smallvec::smallvec![0]),
submitted_at: None,
started_at: None,
finished_at: None,
time_limit: None,
memory_limit_mb: None,
gpu_memory_limit_mb: None,
submitted_by: "testuser".into(),
redone_from,
auto_close_tmux: false,
parameters: gflow::core::job::Parameters::new(),
group_id: None,
max_concurrent: None,
reason: None,
}
}
#[test]
fn test_statue() {
let jobs = vec![
create_test_job_with_state(1, "job-1", JobState::Running),
create_test_job_with_state(2, "job-2", JobState::Finished),
create_test_job_with_state(3, "job-3", JobState::Queued),
create_test_job_with_state(4, "job-4", JobState::Hold),
create_test_job_with_state(5, "job-5", JobState::Failed),
create_test_job_with_state(6, "job-6", JobState::Timeout),
create_test_job_with_state(7, "job-7", JobState::Cancelled),
];
println!();
display_jobs_tree(&jobs, None, &HashSet::new());
}
#[test]
fn test_simple_dependency_tree() {
let jobs = vec![
create_test_job(1, "root-job", None),
create_test_job(2, "child-job-1", Some(1)),
create_test_job(3, "child-job-2", Some(1)),
];
println!();
display_jobs_tree(&jobs, None, &HashSet::new());
}
#[test]
fn test_multi_level_dependency_tree() {
let jobs = vec![
create_test_job(1, "root-job", None),
create_test_job(2, "level-1-job", Some(1)),
create_test_job(3, "level-2-job", Some(2)),
create_test_job(4, "level-3-job", Some(3)),
];
println!();
display_jobs_tree(&jobs, None, &HashSet::new());
}
#[test]
fn test_multiple_independent_trees() {
let jobs = vec![
create_test_job(1, "root-1", None),
create_test_job(2, "child-1-1", Some(1)),
create_test_job(3, "root-2", None),
create_test_job(4, "child-2-1", Some(3)),
create_test_job(5, "child-2-2", Some(3)),
];
println!();
display_jobs_tree(&jobs, None, &HashSet::new());
}
#[test]
fn test_circular_dependency_detection() {
let jobs = vec![
create_test_job(1, "job-1", None),
create_test_job(2, "job-2", Some(1)),
create_test_job(3, "job-3", Some(2)),
];
println!();
display_jobs_tree(&jobs, None, &HashSet::new());
}
#[test]
fn test_missing_parent_job() {
let jobs = vec![
create_test_job(1, "job-1", None),
create_test_job(2, "job-2", Some(99)), create_test_job(3, "job-3", Some(1)),
];
println!();
display_jobs_tree(&jobs, None, &HashSet::new());
}
#[test]
fn test_build_tree_treats_missing_dependency_parent_as_root() {
let jobs = vec![
create_test_job(1, "root", None),
create_test_job(2, "missing-parent", Some(99)),
create_test_job(3, "child-of-1", Some(1)),
];
let tree = build_dependency_tree(&jobs);
let root_ids: Vec<u32> = tree.iter().map(|node| node.job.id).collect();
assert_eq!(root_ids, vec![1, 2]);
let first_children: Vec<u32> = tree[0]
.children
.iter()
.filter_map(|child| match child {
JobNodeChild::Node(node, _) => Some(node.job.id),
JobNodeChild::Reference(_) => None,
})
.collect();
assert_eq!(first_children, vec![3]);
}
#[test]
fn test_build_tree_keeps_node_when_dependency_parent_missing_but_redo_parent_exists() {
let mut redo_with_missing_dep = create_test_job(2, "redo-with-missing-dep", Some(99));
redo_with_missing_dep.redone_from = Some(1);
let jobs = vec![create_test_job(1, "root", None), redo_with_missing_dep];
let tree = build_dependency_tree(&jobs);
let root_ids: Vec<u32> = tree.iter().map(|node| node.job.id).collect();
assert_eq!(root_ids, vec![1]);
let first_children: Vec<u32> = tree[0]
.children
.iter()
.filter_map(|child| match child {
JobNodeChild::Node(node, _) => Some(node.job.id),
JobNodeChild::Reference(_) => None,
})
.collect();
assert_eq!(first_children, vec![2]);
}
#[test]
fn test_gap_job() {
let jobs = vec![
create_test_job(1, "job-1", None),
create_test_job(2, "job-2", None), create_test_job(3, "job-3", Some(1)),
];
println!();
display_jobs_tree(&jobs, None, &HashSet::new());
}
#[test]
fn test_complex_branching_tree() {
let jobs = vec![
create_test_job(1, "root", None),
create_test_job(2, "branch-a", Some(1)),
create_test_job(3, "branch-b", Some(1)),
create_test_job(4, "branch-a-1", Some(2)),
create_test_job(5, "branch-a-2", Some(2)),
create_test_job(6, "branch-b-1", Some(3)),
create_test_job(7, "deep-child", Some(4)),
];
println!();
display_jobs_tree(&jobs, None, &HashSet::new());
}
#[test]
fn test_empty_job_list() {
let jobs: Vec<Job> = vec![];
println!();
display_jobs_tree(&jobs, None, &HashSet::new());
}
#[test]
fn test_tree_with_long_job_names() {
let jobs = vec![
create_test_job(1, "very-long-root-job-name-here", None),
create_test_job(2, "extremely-long-child-job-name", Some(1)),
create_test_job(3, "short", Some(1)),
];
println!();
display_jobs_tree(&jobs, None, &HashSet::new());
}
#[test]
fn test_redo_relationship() {
let jobs = vec![
create_test_job(1, "original-job", None),
create_test_job(2, "dependent-job", Some(1)),
create_test_job_with_redo(3, "redo-of-job-1", Some(1)),
];
println!();
println!("Test: Redo relationship (job 3 is redone from job 1)");
display_jobs_tree(&jobs, None, &HashSet::new());
}
#[test]
fn test_mixed_dependencies_and_redo() {
let jobs = vec![
create_test_job(1, "root", None),
create_test_job(2, "child-dep", Some(1)), create_test_job_with_redo(3, "redo-1", Some(1)), create_test_job(4, "grandchild", Some(2)), ];
println!();
println!("Test: Mixed dependencies and redo relationships");
display_jobs_tree(&jobs, None, &HashSet::new());
}
#[test]
fn test_mixed_dependencies_and_redo_2() {
let jobs = vec![
create_test_job(1, "root", None),
create_test_job_with_redo(2, "redo-1", Some(1)), create_test_job_with_redo(3, "redo-1", Some(1)), create_test_job(4, "grandchild", Some(2)), ];
println!();
println!("Test: Mixed dependencies and redo relationships");
display_jobs_tree(&jobs, None, &HashSet::new());
}
#[test]
fn test_job_with_both_dependency_and_redo() {
let mut job_165 = create_test_job(165, "gflow-job-165", Some(163));
job_165.redone_from = Some(164);
let jobs = vec![
create_test_job(162, "gflow-job-162", None),
create_test_job(163, "gflow-job-163", Some(162)),
create_test_job(164, "gflow-job-164", Some(163)),
job_165,
];
println!();
println!("Test: Job with both dependency and redo relationship (user's scenario)");
println!("Job 165 depends on 163 AND is a redo of 164");
println!("Expected: Job 165 appears once under 163, with '→ see job 165 below' reference under 164");
display_jobs_tree(&jobs, None, &HashSet::new());
}
#[test]
fn test_repeated_redo_operations() {
let jobs = vec![
create_test_job(100, "original-job", None),
create_test_job_with_redo(101, "redo-1", Some(100)),
create_test_job_with_redo(102, "redo-2", Some(101)),
create_test_job_with_redo(103, "redo-3", Some(102)),
];
println!();
println!("Test: Repeated redo operations (chain of redos)");
println!("100 -> 101 (redo of 100) -> 102 (redo of 101) -> 103 (redo of 102)");
display_jobs_tree(&jobs, None, &HashSet::new());
}
#[test]
fn test_multiple_redos_of_same_job() {
let jobs = vec![
create_test_job(200, "original-job", None),
create_test_job_with_redo(201, "redo-attempt-1", Some(200)),
create_test_job_with_redo(202, "redo-attempt-2", Some(200)),
create_test_job_with_redo(203, "redo-attempt-3", Some(200)),
];
println!();
println!("Test: Multiple redos of the same job");
println!("Jobs 201, 202, 203 are all redos of job 200");
display_jobs_tree(&jobs, None, &HashSet::new());
}
#[test]
fn test_redo_with_dependencies() {
let jobs = vec![
create_test_job(300, "original-job", None),
create_test_job(301, "child-of-original", Some(300)),
create_test_job_with_redo(302, "redo-job", Some(300)),
create_test_job(303, "child-of-redo", Some(302)),
];
println!();
println!("Test: Redo job with its own dependencies");
println!("300 -> 301 (depends on 300)");
println!("302 (redo of 300) -> 303 (depends on 302)");
display_jobs_tree(&jobs, None, &HashSet::new());
}
#[test]
fn test_complex_redo_and_dependency_mix() {
let mut job_403 = create_test_job(403, "redo-of-401", Some(400));
job_403.redone_from = Some(401);
let mut job_404 = create_test_job(404, "redo-of-402", Some(403));
job_404.redone_from = Some(402);
let jobs = vec![
create_test_job(400, "root-job", None),
create_test_job(401, "child-1", Some(400)),
create_test_job(402, "grandchild", Some(401)),
job_403,
job_404,
];
println!();
println!("Test: Complex mix of dependencies and redos");
println!("400 -> 401 -> 402");
println!("403 (redo of 401, depends on 400)");
println!("404 (redo of 402, depends on 403)");
display_jobs_tree(&jobs, None, &HashSet::new());
}
#[test]
fn test_redo_chain_with_dependency_conflict() {
let mut job_502 = create_test_job(502, "redo-depends-on-child", Some(501));
job_502.redone_from = Some(500);
let jobs = vec![
create_test_job(500, "original", None),
create_test_job(501, "child", Some(500)),
job_502,
];
println!();
println!("Test: Redo chain with dependency conflict");
println!("500 -> 501");
println!("502 (redo of 500, but depends on 501)");
println!("Expected: 502 appears under 501, reference under 500");
display_jobs_tree(&jobs, None, &HashSet::new());
}
#[test]
fn test_multiple_redo_references_same_job() {
let job_603 = create_test_job_with_redo(603, "redo-1-of-602", Some(602));
let job_604 = create_test_job_with_redo(604, "redo-2-of-602", Some(602));
let jobs = vec![
create_test_job(600, "root", None),
create_test_job(601, "child", Some(600)),
create_test_job(602, "grandchild", Some(601)),
job_603,
job_604,
];
println!();
println!("Test: Multiple redo references to same job");
println!("600 -> 601 -> 602");
println!("603 and 604 are both redos of 602");
println!("Expected: 602 appears under 601, 603 and 604 are root jobs with redo indicators");
display_jobs_tree(&jobs, None, &HashSet::new());
}
}