From 1720698a7a1ba6b1d2f8bf7da7540c02e9557353 Mon Sep 17 00:00:00 2001 From: easong-openai Date: Wed, 5 Nov 2025 14:41:56 -0800 Subject: [PATCH] codex cloud list tasks --- codex-rs/Cargo.lock | 1 + codex-rs/backend-client/src/client.rs | 6 + codex-rs/cloud-tasks-client/src/api.rs | 10 + codex-rs/cloud-tasks-client/src/http.rs | 28 +- codex-rs/cloud-tasks/Cargo.toml | 1 + codex-rs/cloud-tasks/src/cli.rs | 28 ++ codex-rs/cloud-tasks/src/lib.rs | 530 ++++++++++++++++++++++++ 7 files changed, 598 insertions(+), 6 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index c4121da244..108b807f48 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1013,6 +1013,7 @@ dependencies = [ "codex-login", "codex-tui", "crossterm", + "pretty_assertions", "ratatui", "reqwest", "serde", diff --git a/codex-rs/backend-client/src/client.rs b/codex-rs/backend-client/src/client.rs index 28a51598eb..a21e9662cc 100644 --- a/codex-rs/backend-client/src/client.rs +++ b/codex-rs/backend-client/src/client.rs @@ -171,6 +171,7 @@ impl Client { limit: Option, task_filter: Option<&str>, environment_id: Option<&str>, + cursor: Option<&str>, ) -> Result { let url = match self.path_style { PathStyle::CodexApi => format!("{}/api/codex/tasks/list", self.base_url), @@ -192,6 +193,11 @@ impl Client { } else { req }; + let req = if let Some(c) = cursor { + req.query(&[("cursor", c)]) + } else { + req + }; let (body, ct) = self.exec_request(req, "GET", &url).await?; self.decode_json::(&url, &ct, &body) } diff --git a/codex-rs/cloud-tasks-client/src/api.rs b/codex-rs/cloud-tasks-client/src/api.rs index 4bd12939e8..15fbaf8a53 100644 --- a/codex-rs/cloud-tasks-client/src/api.rs +++ b/codex-rs/cloud-tasks-client/src/api.rs @@ -127,6 +127,16 @@ impl Default for TaskText { #[async_trait::async_trait] pub trait CloudBackend: Send + Sync { async fn list_tasks(&self, env: Option<&str>) -> Result>; + async fn list_tasks_page( + &self, + _cursor: Option<&str>, + limit: usize, + env: Option<&str>, + ) -> Result<(Vec, Option)> { + let tasks = self.list_tasks(env).await?; + let page = tasks.into_iter().take(limit).collect(); + Ok((page, None)) + } async fn get_task_diff(&self, id: TaskId) -> Result>; /// Return assistant output messages (no diff) when available. async fn get_task_messages(&self, id: TaskId) -> Result>; diff --git a/codex-rs/cloud-tasks-client/src/http.rs b/codex-rs/cloud-tasks-client/src/http.rs index 57d39b7bda..86d574a56a 100644 --- a/codex-rs/cloud-tasks-client/src/http.rs +++ b/codex-rs/cloud-tasks-client/src/http.rs @@ -60,7 +60,17 @@ impl HttpClient { #[async_trait::async_trait] impl CloudBackend for HttpClient { async fn list_tasks(&self, env: Option<&str>) -> Result> { - self.tasks_api().list(env).await + let (tasks, _cursor) = self.tasks_api().list_page(env, None, 20).await?; + Ok(tasks) + } + + async fn list_tasks_page( + &self, + cursor: Option<&str>, + limit: usize, + env: Option<&str>, + ) -> Result<(Vec, Option)> { + self.tasks_api().list_page(env, cursor, limit).await } async fn get_task_diff(&self, id: TaskId) -> Result> { @@ -128,10 +138,15 @@ mod api { } } - pub(crate) async fn list(&self, env: Option<&str>) -> Result> { + pub(crate) async fn list_page( + &self, + env: Option<&str>, + cursor: Option<&str>, + limit: usize, + ) -> Result<(Vec, Option)> { let resp = self .backend - .list_tasks(Some(20), Some("current"), env) + .list_tasks(Some(limit as i32), Some("current"), env, cursor) .await .map_err(|e| CloudTaskError::Http(format!("list_tasks failed: {e}")))?; @@ -142,11 +157,12 @@ mod api { .collect(); append_error_log(&format!( - "http.list_tasks: env={} items={}", + "http.list_tasks: env={} items={} cursor={}", env.unwrap_or(""), - tasks.len() + tasks.len(), + resp.cursor.as_deref().unwrap_or("") )); - Ok(tasks) + Ok((tasks, resp.cursor)) } pub(crate) async fn diff(&self, id: TaskId) -> Result> { diff --git a/codex-rs/cloud-tasks/Cargo.toml b/codex-rs/cloud-tasks/Cargo.toml index 46044fbb8d..ce7e2e9b1e 100644 --- a/codex-rs/cloud-tasks/Cargo.toml +++ b/codex-rs/cloud-tasks/Cargo.toml @@ -36,3 +36,4 @@ unicode-width = { workspace = true } [dev-dependencies] async-trait = { workspace = true } +pretty_assertions = { workspace = true } diff --git a/codex-rs/cloud-tasks/src/cli.rs b/codex-rs/cloud-tasks/src/cli.rs index 4122aeff68..84bfbbebef 100644 --- a/codex-rs/cloud-tasks/src/cli.rs +++ b/codex-rs/cloud-tasks/src/cli.rs @@ -16,6 +16,8 @@ pub struct Cli { pub enum Command { /// Submit a new Codex Cloud task without launching the TUI. Exec(ExecCommand), + /// List recent Codex Cloud tasks in the terminal. + Tasks(TasksCommand), } #[derive(Debug, Args)] @@ -37,6 +39,21 @@ pub struct ExecCommand { pub attempts: usize, } +#[derive(Debug, Args)] +pub struct TasksCommand { + /// Maximum number of tasks to display (1-20). + #[arg(short = 'l', long = "limit", default_value_t = 10i64, value_parser = parse_limit)] + pub limit: i64, + + /// Filter by environment id/label/repo (e.g., "openai/codex"). + #[arg(short = 'e', long = "env", value_name = "ENV")] + pub environment: Option, + + /// Output as JSON instead of a table. + #[arg(long = "json")] + pub json: bool, +} + fn parse_attempts(input: &str) -> Result { let value: usize = input .parse() @@ -47,3 +64,14 @@ fn parse_attempts(input: &str) -> Result { Err("attempts must be between 1 and 4".to_string()) } } + +fn parse_limit(input: &str) -> Result { + let value: i64 = input + .parse() + .map_err(|_| "limit must be a positive integer".to_string())?; + if value >= 1 { + Ok(value) + } else { + Err("limit must be at least 1".to_string()) + } +} diff --git a/codex-rs/cloud-tasks/src/lib.rs b/codex-rs/cloud-tasks/src/lib.rs index 8f8b3066c9..a00404a0f9 100644 --- a/codex-rs/cloud-tasks/src/lib.rs +++ b/codex-rs/cloud-tasks/src/lib.rs @@ -131,6 +131,160 @@ async fn run_exec_command(args: crate::cli::ExecCommand) -> anyhow::Result<()> { Ok(()) } +async fn run_tasks_command(args: crate::cli::TasksCommand) -> anyhow::Result<()> { + let ctx = init_backend("codex_cloud_tasks_cli").await?; + + let resolved_env = if let Some(requested) = args.environment.as_deref() { + let normalized = util::normalize_base_url(&ctx.base_url); + let headers = util::build_chatgpt_headers().await; + let envs = crate::env_detect::list_environments(&normalized, &headers).await?; + Some(resolve_environment_id_from_list(&envs, requested)?) + } else { + None + }; + + let limit = args.limit as usize; + let mut tasks = fetch_tasks_with_limit(&*ctx.backend, limit, resolved_env.as_deref()).await?; + tasks.sort_by(|a, b| b.updated_at.cmp(&a.updated_at)); + if tasks.len() > limit { + tasks.truncate(limit); + } + + if tasks.is_empty() { + if let Some(env) = resolved_env { + println!("No tasks found for environment {env}."); + } else { + println!("No tasks found."); + } + return Ok(()); + } + + if args.json { + println!("{}", serde_json::to_string_pretty(&tasks)?); + return Ok(()); + } + + let table = render_tasks_table(&tasks); + print!("{table}"); + + Ok(()) +} + +async fn fetch_tasks_with_limit( + backend: &dyn codex_cloud_tasks_client::CloudBackend, + limit: usize, + env: Option<&str>, +) -> anyhow::Result> { + let mut cursor: Option = None; + let mut tasks: Vec = Vec::new(); + while tasks.len() < limit { + let (page, next) = codex_cloud_tasks_client::CloudBackend::list_tasks_page( + backend, + cursor.as_deref(), + 20, // API max page size + env, + ) + .await?; + if page.is_empty() { + break; + } + for task in page { + if task.is_review { + continue; + } + tasks.push(task); + if tasks.len() == limit { + break; + } + } + if next.is_none() { + break; + } + cursor = next; + } + Ok(tasks) +} + +fn status_str(status: &codex_cloud_tasks_client::TaskStatus) -> &'static str { + use codex_cloud_tasks_client::TaskStatus; + match status { + TaskStatus::Pending => "Pending", + TaskStatus::Ready => "Ready", + TaskStatus::Applied => "Applied", + TaskStatus::Error => "Error", + } +} + +fn format_environment_field(task: &codex_cloud_tasks_client::TaskSummary) -> String { + match (&task.environment_label, &task.environment_id) { + (Some(label), Some(id)) if label != id => format!("{label} ({id})"), + (Some(label), _) => label.clone(), + (None, Some(id)) => id.clone(), + _ => "-".to_string(), + } +} + +fn render_tasks_table(tasks: &[codex_cloud_tasks_client::TaskSummary]) -> String { + use chrono::Local; + let updated_width = 19usize; // "YYYY-MM-DD HH:MM:SS" + let status_width = tasks + .iter() + .map(|t| status_str(&t.status).len()) + .max() + .unwrap_or("Status".len()) + .max("Status".len()); + let env_width = tasks + .iter() + .map(format_environment_field) + .map(|s| s.len()) + .max() + .unwrap_or("Environment".len()) + .max("Environment".len()); + let attempts_width = tasks + .iter() + .map(|t| t.attempt_total.unwrap_or(1).to_string().len()) + .max() + .unwrap_or(1) + .max("Attempts".len()); + + let mut out = String::new(); + out.push_str(&format!( + "{:attempts_width$} Title\n", + "Updated", + "Status", + "Environment", + "Attempts", + updated_width = updated_width, + status_width = status_width, + env_width = env_width, + attempts_width = attempts_width + )); + + for task in tasks { + let updated = task + .updated_at + .with_timezone(&Local) + .format("%Y-%m-%d %H:%M:%S"); + let status = status_str(&task.status); + let env_label = format_environment_field(task); + let attempts = task.attempt_total.unwrap_or(1); + out.push_str(&format!( + "{:attempts_width$} {}\n", + updated, + status, + env_label, + attempts, + task.title, + updated_width = updated_width, + status_width = status_width, + env_width = env_width, + attempts_width = attempts_width + )); + } + + out +} + async fn resolve_environment_id(ctx: &BackendContext, requested: &str) -> anyhow::Result { let trimmed = requested.trim(); if trimmed.is_empty() { @@ -176,6 +330,42 @@ async fn resolve_environment_id(ctx: &BackendContext, requested: &str) -> anyhow } } +fn resolve_environment_id_from_list( + envs: &[app::EnvironmentRow], + requested: &str, +) -> anyhow::Result { + let requested_lc = requested.to_lowercase(); + let matches: Vec<&app::EnvironmentRow> = envs + .iter() + .filter(|row| { + row.id == requested + || row + .label + .as_deref() + .map(|label| label.eq_ignore_ascii_case(requested)) + .unwrap_or(false) + || row + .repo_hints + .as_deref() + .map(|h| h.to_lowercase() == requested_lc) + .unwrap_or(false) + }) + .collect(); + match matches.as_slice() { + [] => Err(anyhow!("environment '{requested}' not found")), + [row] => Ok(row.id.clone()), + [first, rest @ ..] => { + if rest.iter().all(|row| row.id == first.id) { + Ok(first.id.clone()) + } else { + Err(anyhow!( + "environment '{requested}' is ambiguous; please specify a unique id" + )) + } + } + } +} + fn resolve_query_input(query_arg: Option) -> anyhow::Result { match query_arg { Some(q) if q != "-" => Ok(q), @@ -332,6 +522,7 @@ pub async fn run_main(cli: Cli, _codex_linux_sandbox_exe: Option) -> an if let Some(command) = cli.command { return match command { crate::cli::Command::Exec(args) => run_exec_command(args).await, + crate::cli::Command::Tasks(args) => run_tasks_command(args).await, }; } let Cli { .. } = cli; @@ -1723,14 +1914,50 @@ fn pretty_lines_from_error(raw: &str) -> Vec { #[cfg(test)] mod tests { + use async_trait::async_trait; + use codex_cloud_tasks_client::CloudBackend; + use codex_cloud_tasks_client::DiffSummary; + use codex_cloud_tasks_client::TaskId; + use codex_cloud_tasks_client::TaskStatus; + use codex_cloud_tasks_client::TaskSummary; use codex_tui::ComposerAction; use codex_tui::ComposerInput; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyModifiers; + use pretty_assertions::assert_eq; use ratatui::buffer::Buffer; use ratatui::layout::Rect; + fn make_task( + id: &str, + title: &str, + status: TaskStatus, + updated: &str, + env_id: Option<&str>, + env_label: Option<&str>, + attempts: Option, + is_review: bool, + ) -> TaskSummary { + TaskSummary { + id: TaskId(id.to_string()), + title: title.to_string(), + status, + updated_at: chrono::DateTime::parse_from_rfc3339(updated) + .unwrap() + .with_timezone(&chrono::Utc), + environment_id: env_id.map(str::to_string), + environment_label: env_label.map(str::to_string), + summary: DiffSummary { + files_changed: 0, + lines_added: 0, + lines_removed: 0, + }, + is_review, + attempt_total: attempts, + } + } + #[test] fn composer_input_renders_typed_characters() { let mut composer = ComposerInput::new(); @@ -1758,4 +1985,307 @@ mod tests { .join(""); assert!(footer.contains("⌃O env")); } + + #[test] + fn resolve_environment_id_from_list_matches_id_label_and_repo() { + let envs = vec![super::app::EnvironmentRow { + id: "env-123".to_string(), + label: Some("Prod".to_string()), + is_pinned: false, + repo_hints: Some("openai/codex".to_string()), + }]; + + assert_eq!( + super::resolve_environment_id_from_list(&envs, "env-123").unwrap(), + "env-123" + ); + assert_eq!( + super::resolve_environment_id_from_list(&envs, "prod").unwrap(), + "env-123" + ); + assert_eq!( + super::resolve_environment_id_from_list(&envs, "openai/codex").unwrap(), + "env-123" + ); + let err = super::resolve_environment_id_from_list(&envs, "missing").unwrap_err(); + assert!(err.to_string().contains("not found")); + } + + struct TestPagedBackend { + pages: Vec>, + } + + #[async_trait] + impl CloudBackend for TestPagedBackend { + async fn list_tasks( + &self, + _env: Option<&str>, + ) -> codex_cloud_tasks_client::Result> { + unimplemented!("list_tasks should not be called in pagination tests") + } + + async fn list_tasks_page( + &self, + cursor: Option<&str>, + limit: usize, + _env: Option<&str>, + ) -> codex_cloud_tasks_client::Result<(Vec, Option)> { + let idx = cursor.and_then(|c| c.parse::().ok()).unwrap_or(0); + if idx >= self.pages.len() { + return Ok((Vec::new(), None)); + } + let mut page = self.pages[idx].clone(); + if page.len() > limit { + page.truncate(limit); + } + let next = if idx + 1 < self.pages.len() { + Some((idx + 1).to_string()) + } else { + None + }; + Ok((page, next)) + } + + async fn get_task_diff( + &self, + _id: TaskId, + ) -> codex_cloud_tasks_client::Result> { + unimplemented!() + } + + async fn get_task_messages( + &self, + _id: TaskId, + ) -> codex_cloud_tasks_client::Result> { + unimplemented!() + } + + async fn get_task_text( + &self, + _id: TaskId, + ) -> codex_cloud_tasks_client::Result { + unimplemented!() + } + + async fn list_sibling_attempts( + &self, + _task: TaskId, + _turn_id: String, + ) -> codex_cloud_tasks_client::Result> { + unimplemented!() + } + + async fn apply_task_preflight( + &self, + _id: TaskId, + _diff_override: Option, + ) -> codex_cloud_tasks_client::Result { + unimplemented!() + } + + async fn apply_task( + &self, + _id: TaskId, + _diff_override: Option, + ) -> codex_cloud_tasks_client::Result { + unimplemented!() + } + + async fn create_task( + &self, + _env_id: &str, + _prompt: &str, + _git_ref: &str, + _qa_mode: bool, + _best_of_n: usize, + ) -> codex_cloud_tasks_client::Result { + unimplemented!() + } + } + + #[tokio::test] + async fn fetch_tasks_with_limit_paginates() { + let pages = vec![ + vec![ + make_task( + "T1", + "one", + TaskStatus::Ready, + "2025-10-01T10:00:00Z", + None, + None, + None, + false, + ), + make_task( + "T2", + "two", + TaskStatus::Ready, + "2025-10-01T09:00:00Z", + None, + None, + None, + false, + ), + ], + vec![make_task( + "T3", + "three", + TaskStatus::Ready, + "2025-10-01T08:00:00Z", + None, + None, + None, + false, + )], + ]; + let backend = TestPagedBackend { pages }; + let tasks = super::fetch_tasks_with_limit(&backend, 3, None) + .await + .unwrap(); + assert_eq!(tasks.len(), 3); + assert_eq!( + tasks.iter().map(|t| t.id.0.clone()).collect::>(), + vec!["T1".to_string(), "T2".to_string(), "T3".to_string()] + ); + } + + #[tokio::test] + async fn fetch_tasks_with_limit_skips_reviews_until_limit_met() { + let pages = vec![ + vec![ + make_task( + "R1", + "review", + TaskStatus::Ready, + "2025-10-01T10:00:00Z", + None, + None, + None, + true, + ), + make_task( + "T1", + "one", + TaskStatus::Ready, + "2025-10-01T09:00:00Z", + None, + None, + None, + false, + ), + ], + vec![make_task( + "T2", + "two", + TaskStatus::Ready, + "2025-10-01T08:00:00Z", + None, + None, + None, + false, + )], + ]; + let backend = TestPagedBackend { pages }; + let tasks = super::fetch_tasks_with_limit(&backend, 2, None) + .await + .unwrap(); + assert_eq!( + tasks.iter().map(|t| t.id.0.clone()).collect::>(), + vec!["T1".to_string(), "T2".to_string()] + ); + } + + #[test] + fn render_tasks_table_formats_columns() { + let tasks = vec![ + make_task( + "T-1", + "Find default juice level for codex web tasks", + TaskStatus::Ready, + "2025-10-01T20:51:20Z", + None, + Some("openai/openai (applied)"), + Some(4), + false, + ), + make_task( + "T-2", + "Debug codex cloud upload", + TaskStatus::Error, + "2025-10-01T19:56:55Z", + Some("openai/codex"), + None, + Some(1), + false, + ), + ]; + + fn split_columns(line: &str) -> Vec { + let mut cols = Vec::new(); + let mut current = String::new(); + let mut space_run = 0; + for ch in line.chars() { + if ch == ' ' { + space_run += 1; + if space_run == 2 { + if !current.is_empty() { + cols.push(current.clone()); + current.clear(); + } + continue; + } + } else { + space_run = 0; + } + current.push(ch); + } + if !current.is_empty() { + cols.push(current); + } + cols.into_iter().map(|c| c.trim().to_string()).collect() + } + + let table = super::render_tasks_table(&tasks); + let mut lines = table.lines(); + let header = split_columns(lines.next().unwrap()); + assert_eq!( + header, + vec!["Updated", "Status", "Environment", "Attempts", "Title"] + ); + + let expected_first_ts = chrono::DateTime::parse_from_rfc3339("2025-10-01T20:51:20Z") + .unwrap() + .with_timezone(&chrono::Local) + .format("%Y-%m-%d %H:%M:%S") + .to_string(); + let expected_second_ts = chrono::DateTime::parse_from_rfc3339("2025-10-01T19:56:55Z") + .unwrap() + .with_timezone(&chrono::Local) + .format("%Y-%m-%d %H:%M:%S") + .to_string(); + + let first = split_columns(lines.next().unwrap()); + assert_eq!( + first, + vec![ + expected_first_ts, + "Ready".to_string(), + "openai/openai (applied)".to_string(), + "4".to_string(), + "Find default juice level for codex web tasks".to_string() + ] + ); + let second = split_columns(lines.next().unwrap()); + assert_eq!( + second, + vec![ + expected_second_ts, + "Error".to_string(), + "openai/codex".to_string(), + "1".to_string(), + "Debug codex cloud upload".to_string() + ] + ); + } }