Codex Handbook
cloud-tasks-client/src/http.rs 918 lines
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>) -> anyhow::Result<Self> {        let base_url = base_url.into();        let backend = backend::Client::new(base_url.clone())?;        Ok(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}");    }}