mirror of
https://github.com/openai/codex.git
synced 2026-05-02 12:21:26 +03:00
@@ -123,9 +123,13 @@ pub async fn load_tasks(
|
||||
env: Option<&str>,
|
||||
) -> anyhow::Result<Vec<TaskSummary>> {
|
||||
// In later milestones, add a small debounce, spinner, and error display.
|
||||
let tasks = tokio::time::timeout(Duration::from_secs(5), backend.list_tasks(env)).await??;
|
||||
let tasks = tokio::time::timeout(
|
||||
Duration::from_secs(5),
|
||||
backend.list_tasks(env, Some(20), None),
|
||||
)
|
||||
.await??;
|
||||
// Hide review-only tasks from the main list.
|
||||
let filtered: Vec<TaskSummary> = tasks.into_iter().filter(|t| !t.is_review).collect();
|
||||
let filtered: Vec<TaskSummary> = tasks.tasks.into_iter().filter(|t| !t.is_review).collect();
|
||||
Ok(filtered)
|
||||
}
|
||||
|
||||
@@ -362,7 +366,9 @@ mod tests {
|
||||
async fn list_tasks(
|
||||
&self,
|
||||
env: Option<&str>,
|
||||
) -> codex_cloud_tasks_client::Result<Vec<TaskSummary>> {
|
||||
limit: Option<i64>,
|
||||
cursor: Option<&str>,
|
||||
) -> codex_cloud_tasks_client::Result<codex_cloud_tasks_client::TaskListPage> {
|
||||
let key = env.map(str::to_string);
|
||||
let titles = self
|
||||
.by_env
|
||||
@@ -383,15 +389,28 @@ mod tests {
|
||||
attempt_total: Some(1),
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
let max = limit.unwrap_or(i64::MAX);
|
||||
let max = max.min(20);
|
||||
let mut limited = Vec::new();
|
||||
for task in out {
|
||||
if (limited.len() as i64) >= max {
|
||||
break;
|
||||
}
|
||||
limited.push(task);
|
||||
}
|
||||
Ok(codex_cloud_tasks_client::TaskListPage {
|
||||
tasks: limited,
|
||||
cursor: cursor.map(str::to_string),
|
||||
})
|
||||
}
|
||||
|
||||
async fn get_task_summary(
|
||||
&self,
|
||||
id: TaskId,
|
||||
) -> codex_cloud_tasks_client::Result<TaskSummary> {
|
||||
self.list_tasks(None)
|
||||
self.list_tasks(None, None, None)
|
||||
.await?
|
||||
.tasks
|
||||
.into_iter()
|
||||
.find(|t| t.id == id)
|
||||
.ok_or_else(|| CloudTaskError::Msg(format!("Task {} not found", id.0)))
|
||||
|
||||
@@ -18,6 +18,8 @@ pub enum Command {
|
||||
Exec(ExecCommand),
|
||||
/// Show the status of a Codex Cloud task.
|
||||
Status(StatusCommand),
|
||||
/// List Codex Cloud tasks.
|
||||
List(ListCommand),
|
||||
/// Apply the diff for a Codex Cloud task locally.
|
||||
Apply(ApplyCommand),
|
||||
/// Show the unified diff for a Codex Cloud task.
|
||||
@@ -58,6 +60,17 @@ fn parse_attempts(input: &str) -> Result<usize, String> {
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_limit(input: &str) -> Result<i64, String> {
|
||||
let value: i64 = input
|
||||
.parse()
|
||||
.map_err(|_| "limit must be an integer between 1 and 20".to_string())?;
|
||||
if (1..=20).contains(&value) {
|
||||
Ok(value)
|
||||
} else {
|
||||
Err("limit must be between 1 and 20".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
pub struct StatusCommand {
|
||||
/// Codex Cloud task identifier to inspect.
|
||||
@@ -65,6 +78,25 @@ pub struct StatusCommand {
|
||||
pub task_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
pub struct ListCommand {
|
||||
/// Filter tasks by environment identifier.
|
||||
#[arg(long = "env", value_name = "ENV_ID")]
|
||||
pub environment: Option<String>,
|
||||
|
||||
/// Maximum number of tasks to return (1-20).
|
||||
#[arg(long = "limit", default_value_t = 20, value_parser = parse_limit, value_name = "N")]
|
||||
pub limit: i64,
|
||||
|
||||
/// Pagination cursor returned by a previous call.
|
||||
#[arg(long = "cursor", value_name = "CURSOR")]
|
||||
pub cursor: Option<String>,
|
||||
|
||||
/// Emit JSON instead of plain text.
|
||||
#[arg(long = "json", default_value_t = false)]
|
||||
pub json: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
pub struct ApplyCommand {
|
||||
/// Codex Cloud task identifier to apply.
|
||||
|
||||
@@ -393,11 +393,10 @@ fn summary_line(summary: &codex_cloud_tasks_client::DiffSummary, colorize: bool)
|
||||
let bullet = "•"
|
||||
.if_supports_color(Stream::Stdout, |t| t.dimmed())
|
||||
.to_string();
|
||||
let file_label = "file"
|
||||
let file_label = format!("file{}", if files == 1 { "" } else { "s" })
|
||||
.if_supports_color(Stream::Stdout, |t| t.dimmed())
|
||||
.to_string();
|
||||
let plural = if files == 1 { "" } else { "s" };
|
||||
format!("{adds_str}/{dels_str} {bullet} {files} {file_label}{plural}")
|
||||
format!("{adds_str}/{dels_str} {bullet} {files} {file_label}")
|
||||
} else {
|
||||
format!(
|
||||
"+{adds}/-{dels} • {files} file{}",
|
||||
@@ -473,6 +472,25 @@ fn format_task_status_lines(
|
||||
lines
|
||||
}
|
||||
|
||||
fn format_task_list_lines(
|
||||
tasks: &[codex_cloud_tasks_client::TaskSummary],
|
||||
base_url: &str,
|
||||
now: chrono::DateTime<Utc>,
|
||||
colorize: bool,
|
||||
) -> Vec<String> {
|
||||
let mut lines = Vec::new();
|
||||
for (idx, task) in tasks.iter().enumerate() {
|
||||
lines.push(util::task_url(base_url, &task.id.0));
|
||||
for line in format_task_status_lines(task, now, colorize) {
|
||||
lines.push(format!(" {line}"));
|
||||
}
|
||||
if idx + 1 < tasks.len() {
|
||||
lines.push(String::new());
|
||||
}
|
||||
}
|
||||
lines
|
||||
}
|
||||
|
||||
async fn run_status_command(args: crate::cli::StatusCommand) -> anyhow::Result<()> {
|
||||
let ctx = init_backend("codex_cloud_tasks_status").await?;
|
||||
let task_id = parse_task_id(&args.task_id)?;
|
||||
@@ -489,6 +507,73 @@ async fn run_status_command(args: crate::cli::StatusCommand) -> anyhow::Result<(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn run_list_command(args: crate::cli::ListCommand) -> anyhow::Result<()> {
|
||||
let ctx = init_backend("codex_cloud_tasks_list").await?;
|
||||
let env_filter = if let Some(env) = args.environment {
|
||||
Some(resolve_environment_id(&ctx, &env).await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let page = codex_cloud_tasks_client::CloudBackend::list_tasks(
|
||||
&*ctx.backend,
|
||||
env_filter.as_deref(),
|
||||
Some(args.limit),
|
||||
args.cursor.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
if args.json {
|
||||
let tasks: Vec<_> = page
|
||||
.tasks
|
||||
.iter()
|
||||
.map(|task| {
|
||||
serde_json::json!({
|
||||
"id": task.id.0,
|
||||
"url": util::task_url(&ctx.base_url, &task.id.0),
|
||||
"title": task.title,
|
||||
"status": task.status,
|
||||
"updated_at": task.updated_at,
|
||||
"environment_id": task.environment_id,
|
||||
"environment_label": task.environment_label,
|
||||
"summary": {
|
||||
"files_changed": task.summary.files_changed,
|
||||
"lines_added": task.summary.lines_added,
|
||||
"lines_removed": task.summary.lines_removed,
|
||||
},
|
||||
"is_review": task.is_review,
|
||||
"attempt_total": task.attempt_total,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let payload = serde_json::json!({
|
||||
"tasks": tasks,
|
||||
"cursor": page.cursor,
|
||||
});
|
||||
println!("{}", serde_json::to_string_pretty(&payload)?);
|
||||
return Ok(());
|
||||
}
|
||||
if page.tasks.is_empty() {
|
||||
println!("No tasks found.");
|
||||
return Ok(());
|
||||
}
|
||||
let now = Utc::now();
|
||||
let colorize = supports_color::on(SupportStream::Stdout).is_some();
|
||||
for line in format_task_list_lines(&page.tasks, &ctx.base_url, now, colorize) {
|
||||
println!("{line}");
|
||||
}
|
||||
if let Some(cursor) = page.cursor {
|
||||
let command = format!("codex cloud list --cursor='{cursor}'");
|
||||
if colorize {
|
||||
println!(
|
||||
"\nTo fetch the next page, run {}",
|
||||
command.if_supports_color(Stream::Stdout, |text| text.cyan())
|
||||
);
|
||||
} else {
|
||||
println!("\nTo fetch the next page, run {command}");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn run_diff_command(args: crate::cli::DiffCommand) -> anyhow::Result<()> {
|
||||
let ctx = init_backend("codex_cloud_tasks_diff").await?;
|
||||
let task_id = parse_task_id(&args.task_id)?;
|
||||
@@ -649,6 +734,7 @@ pub async fn run_main(cli: Cli, _codex_linux_sandbox_exe: Option<PathBuf>) -> an
|
||||
return match command {
|
||||
crate::cli::Command::Exec(args) => run_exec_command(args).await,
|
||||
crate::cli::Command::Status(args) => run_status_command(args).await,
|
||||
crate::cli::Command::List(args) => run_list_command(args).await,
|
||||
crate::cli::Command::Apply(args) => run_apply_command(args).await,
|
||||
crate::cli::Command::Diff(args) => run_diff_command(args).await,
|
||||
};
|
||||
@@ -2181,6 +2267,54 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_task_list_lines_formats_urls() {
|
||||
let now = Utc::now();
|
||||
let tasks = vec![
|
||||
TaskSummary {
|
||||
id: TaskId("task_1".to_string()),
|
||||
title: "Example task".to_string(),
|
||||
status: TaskStatus::Ready,
|
||||
updated_at: now,
|
||||
environment_id: Some("env-1".to_string()),
|
||||
environment_label: Some("Env".to_string()),
|
||||
summary: DiffSummary {
|
||||
files_changed: 3,
|
||||
lines_added: 5,
|
||||
lines_removed: 2,
|
||||
},
|
||||
is_review: false,
|
||||
attempt_total: None,
|
||||
},
|
||||
TaskSummary {
|
||||
id: TaskId("task_2".to_string()),
|
||||
title: "No diff task".to_string(),
|
||||
status: TaskStatus::Pending,
|
||||
updated_at: now,
|
||||
environment_id: Some("env-2".to_string()),
|
||||
environment_label: None,
|
||||
summary: DiffSummary::default(),
|
||||
is_review: false,
|
||||
attempt_total: Some(1),
|
||||
},
|
||||
];
|
||||
let lines = format_task_list_lines(&tasks, "https://chatgpt.com/backend-api", now, false);
|
||||
assert_eq!(
|
||||
lines,
|
||||
vec![
|
||||
"https://chatgpt.com/codex/tasks/task_1".to_string(),
|
||||
" [READY] Example task".to_string(),
|
||||
" Env • 0s ago".to_string(),
|
||||
" +5/-2 • 3 files".to_string(),
|
||||
String::new(),
|
||||
"https://chatgpt.com/codex/tasks/task_2".to_string(),
|
||||
" [PENDING] No diff task".to_string(),
|
||||
" env-2 • 0s ago".to_string(),
|
||||
" no diff".to_string(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn collect_attempt_diffs_includes_sibling_attempts() {
|
||||
let backend = MockClient;
|
||||
|
||||
Reference in New Issue
Block a user