| use crate::ApplyOutcome; |
| use crate::ApplyStatus; |
| use crate::AttemptStatus; |
| use crate::CloudBackend; |
| use crate::CloudBackendFuture; |
| use crate::CloudTaskError; |
| use crate::DiffSummary; |
| use crate::Result; |
| use crate::TaskId; |
| use crate::TaskListPage; |
| use crate::TaskStatus; |
| use crate::TaskSummary; |
| use crate::TurnAttempt; |
| use crate::api::TaskText; |
| use chrono::DateTime; |
| use chrono::Utc; |
|
|
| use codex_api::SharedAuthProvider; |
| use codex_backend_client as backend; |
| use codex_backend_client::CodeTaskDetailsResponseExt; |
| use codex_git_utils::ApplyGitRequest; |
| use codex_git_utils::apply_git_patch; |
|
|
| #[derive(Clone)] |
| pub struct HttpClient { |
| pub base_url: String, |
| backend: backend::Client, |
| } |
|
|
| impl HttpClient { |
| pub fn new( |
| base_url: impl Into<String>, |
| http_client_factory: codex_http_client::HttpClientFactory, |
| ) -> Self { |
| let base_url = base_url.into(); |
| let backend = backend::Client::new_without_redirects(base_url.clone(), http_client_factory); |
| Self { base_url, backend } |
| } |
|
|
| pub fn with_user_agent(mut self, ua: impl Into<String>) -> Self { |
| self.backend = self.backend.clone().with_user_agent(ua); |
| self |
| } |
|
|
| pub fn with_auth_provider(mut self, auth: SharedAuthProvider) -> Self { |
| self.backend = self.backend.clone().with_auth_provider(auth); |
| self |
| } |
|
|
| pub fn with_chatgpt_account_id(mut self, account_id: impl Into<String>) -> Self { |
| self.backend = self.backend.clone().with_chatgpt_account_id(account_id); |
| self |
| } |
|
|
| fn tasks_api(&self) -> api::Tasks<'_> { |
| api::Tasks::new(self) |
| } |
|
|
| fn attempts_api(&self) -> api::Attempts<'_> { |
| api::Attempts::new(self) |
| } |
|
|
| fn apply_api(&self) -> api::Apply<'_> { |
| api::Apply::new(self) |
| } |
| } |
|
|
| impl CloudBackend for HttpClient { |
| fn list_tasks<'a>( |
| &'a self, |
| env: Option<&'a str>, |
| limit: Option<i64>, |
| cursor: Option<&'a str>, |
| ) -> CloudBackendFuture<'a, TaskListPage> { |
| Box::pin(async move { self.tasks_api().list(env, limit, cursor).await }) |
| } |
|
|
| fn get_task_summary(&self, id: TaskId) -> CloudBackendFuture<'_, TaskSummary> { |
| Box::pin(async move { self.tasks_api().summary(id).await }) |
| } |
|
|
| fn get_task_diff(&self, id: TaskId) -> CloudBackendFuture<'_, Option<String>> { |
| Box::pin(async move { self.tasks_api().diff(id).await }) |
| } |
|
|
| fn get_task_messages(&self, id: TaskId) -> CloudBackendFuture<'_, Vec<String>> { |
| Box::pin(async move { self.tasks_api().messages(id).await }) |
| } |
|
|
| fn get_task_text(&self, id: TaskId) -> CloudBackendFuture<'_, TaskText> { |
| Box::pin(async move { self.tasks_api().task_text(id).await }) |
| } |
|
|
| fn list_sibling_attempts( |
| &self, |
| task: TaskId, |
| turn_id: String, |
| ) -> CloudBackendFuture<'_, Vec<TurnAttempt>> { |
| Box::pin(async move { self.attempts_api().list(task, turn_id).await }) |
| } |
|
|
| fn apply_task( |
| &self, |
| id: TaskId, |
| diff_override: Option<String>, |
| ) -> CloudBackendFuture<'_, ApplyOutcome> { |
| Box::pin(async move { |
| self.apply_api() |
| .run(id, diff_override, false) |
| .await |
| }) |
| } |
|
|
| fn apply_task_preflight( |
| &self, |
| id: TaskId, |
| diff_override: Option<String>, |
| ) -> CloudBackendFuture<'_, ApplyOutcome> { |
| Box::pin(async move { |
| self.apply_api() |
| .run(id, diff_override, true) |
| .await |
| }) |
| } |
|
|
| fn create_task<'a>( |
| &'a self, |
| env_id: &'a str, |
| prompt: &'a str, |
| git_ref: &'a str, |
| qa_mode: bool, |
| best_of_n: usize, |
| ) -> CloudBackendFuture<'a, crate::CreatedTask> { |
| Box::pin(async move { |
| self.tasks_api() |
| .create(env_id, prompt, git_ref, qa_mode, best_of_n) |
| .await |
| }) |
| } |
| } |
|
|
| mod api { |
| use super::*; |
| use serde_json::Value; |
| use std::cmp::Ordering; |
| use std::collections::HashMap; |
|
|
| pub(crate) struct Tasks<'a> { |
| base_url: &'a str, |
| backend: &'a backend::Client, |
| } |
|
|
| impl<'a> Tasks<'a> { |
| pub(crate) fn new(client: &'a HttpClient) -> Self { |
| Self { |
| base_url: &client.base_url, |
| backend: &client.backend, |
| } |
| } |
|
|
| pub(crate) async fn list( |
| &self, |
| env: Option<&str>, |
| limit: Option<i64>, |
| cursor: Option<&str>, |
| ) -> Result<TaskListPage> { |
| let limit_i32 = limit.and_then(|lim| i32::try_from(lim).ok()); |
| let resp = self |
| .backend |
| .list_tasks(limit_i32, Some("current"), env, cursor) |
| .await |
| .map_err(|e| CloudTaskError::Http(format!("list_tasks failed: {e}")))?; |
|
|
| let tasks: Vec<TaskSummary> = resp |
| .items |
| .into_iter() |
| .map(map_task_list_item_to_summary) |
| .collect(); |
|
|
| append_error_log(&format!( |
| "http.list_tasks: env={} limit={} cursor_in={} cursor_out={} items={}", |
| env.unwrap_or("<all>"), |
| limit_i32 |
| .map(|v| v.to_string()) |
| .unwrap_or_else(|| "<default>".to_string()), |
| cursor.unwrap_or("<none>"), |
| resp.cursor.as_deref().unwrap_or("<none>"), |
| tasks.len() |
| )); |
| Ok(TaskListPage { |
| tasks, |
| cursor: resp.cursor, |
| }) |
| } |
|
|
| pub(crate) async fn summary(&self, id: TaskId) -> Result<TaskSummary> { |
| let id_str = id.0.clone(); |
| let (details, body, ct) = self |
| .details_with_body(&id.0) |
| .await |
| .map_err(|e| CloudTaskError::Http(format!("get_task_details failed: {e}")))?; |
| let parsed: Value = serde_json::from_str(&body).map_err(|e| { |
| CloudTaskError::Http(format!( |
| "Decode error for {}: {e}; content-type={ct}; body={body}", |
| id.0 |
| )) |
| })?; |
| let task_obj = parsed |
| .get("task") |
| .and_then(Value::as_object) |
| .ok_or_else(|| { |
| CloudTaskError::Http(format!("Task metadata missing from details for {id_str}")) |
| })?; |
| let status_display = parsed |
| .get("task_status_display") |
| .or_else(|| task_obj.get("task_status_display")) |
| .and_then(Value::as_object) |
| .map(|m| { |
| m.iter() |
| .map(|(k, v)| (k.clone(), v.clone())) |
| .collect::<HashMap<String, Value>>() |
| }); |
| let status = map_status(status_display.as_ref()); |
| let mut summary = diff_summary_from_status_display(status_display.as_ref()); |
| if summary.files_changed == 0 |
| && summary.lines_added == 0 |
| && summary.lines_removed == 0 |
| && let Some(diff) = details.unified_diff() |
| { |
| summary = diff_summary_from_diff(&diff); |
| } |
| let updated_at_raw = task_obj |
| .get("updated_at") |
| .and_then(Value::as_f64) |
| .or_else(|| task_obj.get("created_at").and_then(Value::as_f64)) |
| .or_else(|| latest_turn_timestamp(status_display.as_ref())); |
| let environment_id = task_obj |
| .get("environment_id") |
| .and_then(Value::as_str) |
| .map(str::to_string); |
| let environment_label = env_label_from_status_display(status_display.as_ref()); |
| let attempt_total = attempt_total_from_status_display(status_display.as_ref()); |
| let title = task_obj |
| .get("title") |
| .and_then(Value::as_str) |
| .unwrap_or("<untitled>") |
| .to_string(); |
| let is_review = task_obj |
| .get("is_review") |
| .and_then(Value::as_bool) |
| .unwrap_or(false); |
| Ok(TaskSummary { |
| id, |
| title, |
| status, |
| updated_at: parse_updated_at(updated_at_raw.as_ref()), |
| environment_id, |
| environment_label, |
| summary, |
| is_review, |
| attempt_total, |
| }) |
| } |
|
|
| pub(crate) async fn diff(&self, id: TaskId) -> Result<Option<String>> { |
| let (details, body, ct) = self |
| .details_with_body(&id.0) |
| .await |
| .map_err(|e| CloudTaskError::Http(format!("get_task_details failed: {e}")))?; |
| if let Some(diff) = details.unified_diff() { |
| return Ok(Some(diff)); |
| } |
| let _ = (body, ct); |
| Ok(None) |
| } |
|
|
| pub(crate) async fn messages(&self, id: TaskId) -> Result<Vec<String>> { |
| let (details, body, ct) = self |
| .details_with_body(&id.0) |
| .await |
| .map_err(|e| CloudTaskError::Http(format!("get_task_details failed: {e}")))?; |
|
|
| let mut msgs = details.assistant_text_messages(); |
| if msgs.is_empty() { |
| msgs.extend(extract_assistant_messages_from_body(&body)); |
| } |
| if !msgs.is_empty() { |
| return Ok(msgs); |
| } |
| if let Some(err) = details.assistant_error_message() { |
| return Ok(vec![format!("Task failed: {err}")]); |
| } |
|
|
| let url = match details_path(self.base_url, &id.0) { |
| Some(url) => url, |
| None => format!("{}/api/codex/tasks/{}", self.base_url, id.0), |
| }; |
| Err(CloudTaskError::Http(format!( |
| "No assistant text messages in response. GET {url}; content-type={ct}; body={body}" |
| ))) |
| } |
|
|
| pub(crate) async fn task_text(&self, id: TaskId) -> Result<TaskText> { |
| let (details, body, _ct) = self |
| .details_with_body(&id.0) |
| .await |
| .map_err(|e| CloudTaskError::Http(format!("get_task_details failed: {e}")))?; |
| let prompt = details.user_text_prompt(); |
| let mut messages = details.assistant_text_messages(); |
| if messages.is_empty() { |
| messages.extend(extract_assistant_messages_from_body(&body)); |
| } |
| let assistant_turn = details.current_assistant_turn.as_ref(); |
| let turn_id = assistant_turn.and_then(|turn| turn.id.clone()); |
| let sibling_turn_ids = assistant_turn |
| .map(|turn| turn.sibling_turn_ids.clone()) |
| .unwrap_or_default(); |
| let attempt_placement = assistant_turn.and_then(|turn| turn.attempt_placement); |
| let attempt_status = attempt_status_from_str( |
| assistant_turn.and_then(|turn| turn.turn_status.as_deref()), |
| ); |
| Ok(TaskText { |
| prompt, |
| messages, |
| turn_id, |
| sibling_turn_ids, |
| attempt_placement, |
| attempt_status, |
| }) |
| } |
|
|
| pub(crate) async fn create( |
| &self, |
| env_id: &str, |
| prompt: &str, |
| git_ref: &str, |
| qa_mode: bool, |
| best_of_n: usize, |
| ) -> Result<crate::CreatedTask> { |
| let mut input_items: Vec<serde_json::Value> = Vec::new(); |
| input_items.push(serde_json::json!({ |
| "type": "message", |
| "role": "user", |
| "content": [{ "content_type": "text", "text": prompt }] |
| })); |
|
|
| if let Ok(diff) = std::env::var("CODEX_STARTING_DIFF") |
| && !diff.is_empty() |
| { |
| input_items.push(serde_json::json!({ |
| "type": "pre_apply_patch", |
| "output_diff": { "diff": diff } |
| })); |
| } |
|
|
| let mut request_body = serde_json::json!({ |
| "new_task": { |
| "environment_id": env_id, |
| "branch": git_ref, |
| "run_environment_in_qa_mode": qa_mode, |
| }, |
| "input_items": input_items, |
| }); |
|
|
| if best_of_n > 1 |
| && let Some(obj) = request_body.as_object_mut() |
| { |
| obj.insert( |
| "metadata".to_string(), |
| serde_json::json!({ "best_of_n": best_of_n }), |
| ); |
| } |
|
|
| match self.backend.create_task(request_body).await { |
| Ok(id) => { |
| append_error_log(&format!( |
| "new_task: created id={id} env={} prompt_chars={}", |
| env_id, |
| prompt.chars().count() |
| )); |
| Ok(crate::CreatedTask { id: TaskId(id) }) |
| } |
| Err(e) => { |
| append_error_log(&format!( |
| "new_task: create failed env={} prompt_chars={}: {}", |
| env_id, |
| prompt.chars().count(), |
| e |
| )); |
| Err(CloudTaskError::Http(format!("create_task failed: {e}"))) |
| } |
| } |
| } |
|
|
| async fn details_with_body( |
| &self, |
| id: &str, |
| ) -> anyhow::Result<(backend::CodeTaskDetailsResponse, String, String)> { |
| let (parsed, body, ct) = self.backend.get_task_details_with_body(id).await?; |
| Ok((parsed, body, ct)) |
| } |
| } |
|
|
| pub(crate) struct Attempts<'a> { |
| backend: &'a backend::Client, |
| } |
|
|
| impl<'a> Attempts<'a> { |
| pub(crate) fn new(client: &'a HttpClient) -> Self { |
| Self { |
| backend: &client.backend, |
| } |
| } |
|
|
| pub(crate) async fn list(&self, task: TaskId, turn_id: String) -> Result<Vec<TurnAttempt>> { |
| let resp = self |
| .backend |
| .list_sibling_turns(&task.0, &turn_id) |
| .await |
| .map_err(|e| CloudTaskError::Http(format!("list_sibling_turns failed: {e}")))?; |
|
|
| let mut attempts: Vec<TurnAttempt> = resp |
| .sibling_turns |
| .iter() |
| .filter_map(turn_attempt_from_map) |
| .collect(); |
| attempts.sort_by(compare_attempts); |
| Ok(attempts) |
| } |
| } |
|
|
| pub(crate) struct Apply<'a> { |
| backend: &'a backend::Client, |
| } |
|
|
| impl<'a> Apply<'a> { |
| pub(crate) fn new(client: &'a HttpClient) -> Self { |
| Self { |
| backend: &client.backend, |
| } |
| } |
|
|
| pub(crate) async fn run( |
| &self, |
| task_id: TaskId, |
| diff_override: Option<String>, |
| preflight: bool, |
| ) -> Result<ApplyOutcome> { |
| let id = task_id.0.clone(); |
| let diff = match diff_override { |
| Some(diff) => diff, |
| None => { |
| let details = self.backend.get_task_details(&id).await.map_err(|e| { |
| CloudTaskError::Http(format!("get_task_details failed: {e}")) |
| })?; |
| details.unified_diff().ok_or_else(|| { |
| CloudTaskError::Msg(format!("No diff available for task {id}")) |
| })? |
| } |
| }; |
|
|
| if !is_unified_diff(&diff) { |
| let summary = summarize_patch_for_logging(&diff); |
| let mode = if preflight { "preflight" } else { "apply" }; |
| append_error_log(&format!( |
| "apply_error: id={id} mode={mode} format=non-unified; {summary}" |
| )); |
| return Ok(ApplyOutcome { |
| applied: false, |
| status: ApplyStatus::Error, |
| message: "Expected unified git diff; backend returned an incompatible format." |
| .to_string(), |
| skipped_paths: Vec::new(), |
| conflict_paths: Vec::new(), |
| }); |
| } |
|
|
| let req = ApplyGitRequest { |
| cwd: std::env::current_dir().unwrap_or_else(|_| std::env::temp_dir()), |
| diff: diff.clone(), |
| revert: false, |
| preflight, |
| }; |
| let r = apply_git_patch(&req) |
| .map_err(|e| CloudTaskError::Io(format!("git apply failed to run: {e}")))?; |
|
|
| let status = if r.exit_code == 0 { |
| ApplyStatus::Success |
| } else if !r.applied_paths.is_empty() || !r.conflicted_paths.is_empty() { |
| ApplyStatus::Partial |
| } else { |
| ApplyStatus::Error |
| }; |
| let applied = matches!(status, ApplyStatus::Success) && !preflight; |
|
|
| let message = if preflight { |
| match status { |
| ApplyStatus::Success => { |
| format!("Preflight passed for task {id} (applies cleanly)") |
| } |
| ApplyStatus::Partial => format!( |
| "Preflight: patch does not fully apply for task {id} (applied={}, skipped={}, conflicts={})", |
| r.applied_paths.len(), |
| r.skipped_paths.len(), |
| r.conflicted_paths.len() |
| ), |
| ApplyStatus::Error => format!( |
| "Preflight failed for task {id} (applied={}, skipped={}, conflicts={})", |
| r.applied_paths.len(), |
| r.skipped_paths.len(), |
| r.conflicted_paths.len() |
| ), |
| } |
| } else { |
| match status { |
| ApplyStatus::Success => format!( |
| "Applied task {id} locally ({} files)", |
| r.applied_paths.len() |
| ), |
| ApplyStatus::Partial => format!( |
| "Apply partially succeeded for task {id} (applied={}, skipped={}, conflicts={})", |
| r.applied_paths.len(), |
| r.skipped_paths.len(), |
| r.conflicted_paths.len() |
| ), |
| ApplyStatus::Error => format!( |
| "Apply failed for task {id} (applied={}, skipped={}, conflicts={})", |
| r.applied_paths.len(), |
| r.skipped_paths.len(), |
| r.conflicted_paths.len() |
| ), |
| } |
| }; |
|
|
| if matches!(status, ApplyStatus::Partial | ApplyStatus::Error) |
| || (preflight && !matches!(status, ApplyStatus::Success)) |
| { |
| let mut log = String::new(); |
| let summary = summarize_patch_for_logging(&diff); |
| let mode = if preflight { "preflight" } else { "apply" }; |
| use std::fmt::Write as _; |
| let _ = writeln!( |
| &mut log, |
| "apply_result: mode={} id={} status={:?} applied={} skipped={} conflicts={} cmd={}", |
| mode, |
| id, |
| status, |
| r.applied_paths.len(), |
| r.skipped_paths.len(), |
| r.conflicted_paths.len(), |
| r.cmd_for_log |
| ); |
| let _ = writeln!( |
| &mut log, |
| "stdout_tail=\n{}\nstderr_tail=\n{}", |
| tail(&r.stdout, 2000), |
| tail(&r.stderr, 2000) |
| ); |
| let _ = writeln!(&mut log, "{summary}"); |
| let _ = writeln!( |
| &mut log, |
| "----- PATCH BEGIN -----\n{diff}\n----- PATCH END -----" |
| ); |
| append_error_log(&log); |
| } |
|
|
| Ok(ApplyOutcome { |
| applied, |
| status, |
| message, |
| skipped_paths: r.skipped_paths, |
| conflict_paths: r.conflicted_paths, |
| }) |
| } |
| } |
|
|
| fn details_path(base_url: &str, id: &str) -> Option<String> { |
| if base_url.contains("/backend-api") { |
| Some(format!("{base_url}/wham/tasks/{id}")) |
| } else if base_url.contains("/api/codex") { |
| Some(format!("{base_url}/tasks/{id}")) |
| } else { |
| None |
| } |
| } |
|
|
| fn extract_assistant_messages_from_body(body: &str) -> Vec<String> { |
| let mut msgs = Vec::new(); |
| if let Ok(full) = serde_json::from_str::<serde_json::Value>(body) |
| && let Some(arr) = full |
| .get("current_assistant_turn") |
| .and_then(|v| v.get("worklog")) |
| .and_then(|v| v.get("messages")) |
| .and_then(|v| v.as_array()) |
| { |
| for m in arr { |
| let is_assistant = m |
| .get("author") |
| .and_then(|a| a.get("role")) |
| .and_then(|r| r.as_str()) |
| == Some("assistant"); |
| if !is_assistant { |
| continue; |
| } |
| if let Some(parts) = m |
| .get("content") |
| .and_then(|c| c.get("parts")) |
| .and_then(|p| p.as_array()) |
| { |
| for p in parts { |
| if let Some(s) = p.as_str() { |
| if !s.is_empty() { |
| msgs.push(s.to_string()); |
| } |
| continue; |
| } |
| if let Some(obj) = p.as_object() |
| && obj.get("content_type").and_then(|t| t.as_str()) == Some("text") |
| && let Some(txt) = obj.get("text").and_then(|t| t.as_str()) |
| { |
| msgs.push(txt.to_string()); |
| } |
| } |
| } |
| } |
| } |
| msgs |
| } |
|
|
| fn turn_attempt_from_map(turn: &HashMap<String, Value>) -> Option<TurnAttempt> { |
| let turn_id = turn.get("id").and_then(Value::as_str)?.to_string(); |
| let attempt_placement = turn.get("attempt_placement").and_then(Value::as_i64); |
| let created_at = parse_timestamp_value(turn.get("created_at")); |
| let status = attempt_status_from_str(turn.get("turn_status").and_then(Value::as_str)); |
| let diff = extract_diff_from_turn(turn); |
| let messages = extract_assistant_messages_from_turn(turn); |
| Some(TurnAttempt { |
| turn_id, |
| attempt_placement, |
| created_at, |
| status, |
| diff, |
| messages, |
| }) |
| } |
|
|
| fn compare_attempts(a: &TurnAttempt, b: &TurnAttempt) -> Ordering { |
| match (a.attempt_placement, b.attempt_placement) { |
| (Some(lhs), Some(rhs)) => lhs.cmp(&rhs), |
| (Some(_), None) => Ordering::Less, |
| (None, Some(_)) => Ordering::Greater, |
| (None, None) => match (a.created_at, b.created_at) { |
| (Some(lhs), Some(rhs)) => lhs.cmp(&rhs), |
| (Some(_), None) => Ordering::Less, |
| (None, Some(_)) => Ordering::Greater, |
| (None, None) => a.turn_id.cmp(&b.turn_id), |
| }, |
| } |
| } |
|
|
| fn extract_diff_from_turn(turn: &HashMap<String, Value>) -> Option<String> { |
| let items = turn.get("output_items").and_then(Value::as_array)?; |
| for item in items { |
| match item.get("type").and_then(Value::as_str) { |
| Some("output_diff") => { |
| if let Some(diff) = item.get("diff").and_then(Value::as_str) |
| && !diff.is_empty() |
| { |
| return Some(diff.to_string()); |
| } |
| } |
| Some("pr") => { |
| if let Some(diff) = item |
| .get("output_diff") |
| .and_then(Value::as_object) |
| .and_then(|od| od.get("diff")) |
| .and_then(Value::as_str) |
| && !diff.is_empty() |
| { |
| return Some(diff.to_string()); |
| } |
| } |
| _ => {} |
| } |
| } |
| None |
| } |
|
|
| fn extract_assistant_messages_from_turn(turn: &HashMap<String, Value>) -> Vec<String> { |
| let mut msgs = Vec::new(); |
| if let Some(items) = turn.get("output_items").and_then(Value::as_array) { |
| for item in items { |
| if item.get("type").and_then(Value::as_str) != Some("message") { |
| continue; |
| } |
| if let Some(content) = item.get("content").and_then(Value::as_array) { |
| for part in content { |
| if part.get("content_type").and_then(Value::as_str) == Some("text") |
| && let Some(txt) = part.get("text").and_then(Value::as_str) |
| && !txt.is_empty() |
| { |
| msgs.push(txt.to_string()); |
| } |
| } |
| } |
| } |
| } |
| msgs |
| } |
|
|
| fn attempt_status_from_str(raw: Option<&str>) -> AttemptStatus { |
| match raw.unwrap_or_default() { |
| "failed" => AttemptStatus::Failed, |
| "completed" => AttemptStatus::Completed, |
| "in_progress" => AttemptStatus::InProgress, |
| "pending" => AttemptStatus::Pending, |
| _ => AttemptStatus::Pending, |
| } |
| } |
|
|
| fn parse_timestamp_value(v: Option<&Value>) -> Option<DateTime<Utc>> { |
| let ts = v?.as_f64()?; |
| let secs = ts as i64; |
| let nanos = ((ts - secs as f64) * 1_000_000_000.0) as u32; |
| Some(DateTime::<Utc>::from( |
| std::time::UNIX_EPOCH + std::time::Duration::new(secs.max(0) as u64, nanos), |
| )) |
| } |
|
|
| fn map_task_list_item_to_summary(src: backend::TaskListItem) -> TaskSummary { |
| let status_display = src.task_status_display.as_ref(); |
| TaskSummary { |
| id: TaskId(src.id), |
| title: src.title, |
| status: map_status(status_display), |
| updated_at: parse_updated_at(src.updated_at.as_ref()), |
| environment_id: None, |
| environment_label: env_label_from_status_display(status_display), |
| summary: diff_summary_from_status_display(status_display), |
| is_review: src |
| .pull_requests |
| .as_ref() |
| .is_some_and(|prs| !prs.is_empty()), |
| attempt_total: attempt_total_from_status_display(status_display), |
| } |
| } |
|
|
| fn map_status(v: Option<&HashMap<String, Value>>) -> TaskStatus { |
| if let Some(val) = v { |
| if let Some(turn) = val |
| .get("latest_turn_status_display") |
| .and_then(Value::as_object) |
| && let Some(s) = turn.get("turn_status").and_then(Value::as_str) |
| { |
| return match s { |
| "failed" => TaskStatus::Error, |
| "completed" => TaskStatus::Ready, |
| "in_progress" => TaskStatus::Pending, |
| "pending" => TaskStatus::Pending, |
| "cancelled" => TaskStatus::Error, |
| _ => TaskStatus::Pending, |
| }; |
| } |
| if let Some(state) = val.get("state").and_then(Value::as_str) { |
| return match state { |
| "pending" => TaskStatus::Pending, |
| "ready" => TaskStatus::Ready, |
| "applied" => TaskStatus::Applied, |
| "error" => TaskStatus::Error, |
| _ => TaskStatus::Pending, |
| }; |
| } |
| } |
| TaskStatus::Pending |
| } |
|
|
| fn parse_updated_at(ts: Option<&f64>) -> DateTime<Utc> { |
| if let Some(v) = ts { |
| let secs = *v as i64; |
| let nanos = ((*v - secs as f64) * 1_000_000_000.0) as u32; |
| return DateTime::<Utc>::from( |
| std::time::UNIX_EPOCH + std::time::Duration::new(secs.max(0) as u64, nanos), |
| ); |
| } |
| Utc::now() |
| } |
|
|
| fn env_label_from_status_display(v: Option<&HashMap<String, Value>>) -> Option<String> { |
| let map = v?; |
| map.get("environment_label") |
| .and_then(Value::as_str) |
| .map(str::to_string) |
| } |
|
|
| fn diff_summary_from_diff(diff: &str) -> DiffSummary { |
| let mut files_changed = 0usize; |
| let mut lines_added = 0usize; |
| let mut lines_removed = 0usize; |
| for line in diff.lines() { |
| if line.starts_with("diff --git ") { |
| files_changed += 1; |
| continue; |
| } |
| if line.starts_with("+++") || line.starts_with("---") || line.starts_with("@@") { |
| continue; |
| } |
| match line.as_bytes().first() { |
| Some(b'+') => lines_added += 1, |
| Some(b'-') => lines_removed += 1, |
| _ => {} |
| } |
| } |
| if files_changed == 0 && !diff.trim().is_empty() { |
| files_changed = 1; |
| } |
| DiffSummary { |
| files_changed, |
| lines_added, |
| lines_removed, |
| } |
| } |
|
|
| fn diff_summary_from_status_display(v: Option<&HashMap<String, Value>>) -> DiffSummary { |
| let mut out = DiffSummary::default(); |
| let Some(map) = v else { return out }; |
| let latest = map |
| .get("latest_turn_status_display") |
| .and_then(Value::as_object); |
| let Some(latest) = latest else { return out }; |
| if let Some(ds) = latest.get("diff_stats").and_then(Value::as_object) { |
| if let Some(n) = ds.get("files_modified").and_then(Value::as_i64) { |
| out.files_changed = n.max(0) as usize; |
| } |
| if let Some(n) = ds.get("lines_added").and_then(Value::as_i64) { |
| out.lines_added = n.max(0) as usize; |
| } |
| if let Some(n) = ds.get("lines_removed").and_then(Value::as_i64) { |
| out.lines_removed = n.max(0) as usize; |
| } |
| } |
| out |
| } |
|
|
| fn latest_turn_timestamp(v: Option<&HashMap<String, Value>>) -> Option<f64> { |
| let map = v?; |
| let latest = map |
| .get("latest_turn_status_display") |
| .and_then(Value::as_object)?; |
| latest |
| .get("updated_at") |
| .or_else(|| latest.get("created_at")) |
| .and_then(Value::as_f64) |
| } |
|
|
| fn attempt_total_from_status_display(v: Option<&HashMap<String, Value>>) -> Option<usize> { |
| let map = v?; |
| let latest = map |
| .get("latest_turn_status_display") |
| .and_then(Value::as_object)?; |
| let siblings = latest.get("sibling_turn_ids").and_then(Value::as_array)?; |
| Some(siblings.len().saturating_add(1)) |
| } |
|
|
| fn is_unified_diff(diff: &str) -> bool { |
| let t = diff.trim_start(); |
| if t.starts_with("diff --git ") { |
| return true; |
| } |
| let has_dash_headers = diff.contains("\n--- ") && diff.contains("\n+++ "); |
| let has_hunk = diff.contains("\n@@ ") || diff.starts_with("@@ "); |
| has_dash_headers && has_hunk |
| } |
|
|
| fn tail(s: &str, max: usize) -> String { |
| if s.len() <= max { |
| s.to_string() |
| } else { |
| s[s.len() - max..].to_string() |
| } |
| } |
|
|
| fn summarize_patch_for_logging(patch: &str) -> String { |
| let trimmed = patch.trim_start(); |
| let kind = if trimmed.starts_with("*** Begin Patch") { |
| "codex-patch" |
| } else if trimmed.starts_with("diff --git ") || trimmed.contains("\n*** End Patch\n") { |
| "git-diff" |
| } else if trimmed.starts_with("@@ ") || trimmed.contains("\n@@ ") { |
| "unified-diff" |
| } else { |
| "unknown" |
| }; |
| let lines = patch.lines().count(); |
| let chars = patch.len(); |
| let cwd = std::env::current_dir() |
| .ok() |
| .map(|p| p.display().to_string()) |
| .unwrap_or_else(|| "<unknown>".to_string()); |
| let head: String = patch.lines().take(20).collect::<Vec<&str>>().join("\n"); |
| let head_trunc = if head.len() > 800 { |
| format!("{}…", &head[..800]) |
| } else { |
| head |
| }; |
| format!( |
| "patch_summary: kind={kind} lines={lines} chars={chars} cwd={cwd} ; head=\n{head_trunc}" |
| ) |
| } |
| } |
|
|
| fn append_error_log(message: &str) { |
| let ts = Utc::now().to_rfc3339(); |
| if let Ok(mut f) = std::fs::OpenOptions::new() |
| .create(true) |
| .append(true) |
| .open("error.log") |
| { |
| use std::io::Write as _; |
| let _ = writeln!(f, "[{ts}] {message}"); |
| } |
| } |
|
|