Download codex-rs/cloud-tasks-client/src/http.rs from SaylorTwift/codex: direct link, hf CLI and curl.
- Browser
- Download file 33.5 kB
-
https://huggingface.co/SaylorTwift/codex/resolve/main/codex-rs/cloud-tasks-client/src/http.rs
- Command line
-
hf download hf://SaylorTwift/codex/codex-rs/cloud-tasks-client/src/http.rs
-
curl -L -o http.rs https://huggingface.co/SaylorTwift/codex/resolve/main/codex-rs/cloud-tasks-client/src/http.rs
33.5 kB
| 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; | |
| 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, /*preflight*/ 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, /*preflight*/ 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, /*max*/ 2000), | |
| tail(&r.stderr, /*max*/ 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}"); | |
| } | |
| } | |