mirror of
https://github.com/openai/codex.git
synced 2026-04-24 22:54:54 +00:00
improvements
This commit is contained in:
14
codex-rs/Cargo.lock
generated
14
codex-rs/Cargo.lock
generated
@@ -693,6 +693,7 @@ dependencies = [
|
||||
"clap",
|
||||
"codex-common",
|
||||
"codex-core",
|
||||
"codex-git-apply",
|
||||
"codex-protocol",
|
||||
"reqwest",
|
||||
"serde",
|
||||
@@ -761,13 +762,11 @@ dependencies = [
|
||||
"async-trait",
|
||||
"chrono",
|
||||
"codex-backend-client",
|
||||
"codex-git-apply",
|
||||
"diffy",
|
||||
"once_cell",
|
||||
"regex",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"thiserror 2.0.16",
|
||||
"tokio",
|
||||
]
|
||||
@@ -901,6 +900,15 @@ dependencies = [
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codex-git-apply"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"regex",
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codex-linux-sandbox"
|
||||
version = "0.0.0"
|
||||
|
||||
@@ -22,6 +22,7 @@ members = [
|
||||
"protocol",
|
||||
"protocol-ts",
|
||||
"tui",
|
||||
"git-apply",
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
|
||||
@@ -7,11 +7,22 @@ use reqwest::header::HeaderMap;
|
||||
use reqwest::header::HeaderName;
|
||||
use reqwest::header::HeaderValue;
|
||||
use reqwest::header::USER_AGENT;
|
||||
use serde::de::DeserializeOwned;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum PathStyle {
|
||||
CodexApi, // /api/codex/...
|
||||
Wham, // /wham/...
|
||||
CodexApi, // /api/codex/...
|
||||
ChatGptApi, // /wham/...
|
||||
}
|
||||
|
||||
impl PathStyle {
|
||||
pub fn from_base_url(base_url: &str) -> Self {
|
||||
if base_url.contains("/backend-api") {
|
||||
PathStyle::ChatGptApi
|
||||
} else {
|
||||
PathStyle::CodexApi
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -39,11 +50,7 @@ impl Client {
|
||||
base_url = format!("{base_url}/backend-api");
|
||||
}
|
||||
let http = reqwest::Client::builder().build()?;
|
||||
let path_style = if base_url.contains("/backend-api") {
|
||||
PathStyle::Wham
|
||||
} else {
|
||||
PathStyle::CodexApi
|
||||
};
|
||||
let path_style = PathStyle::from_base_url(&base_url);
|
||||
Ok(Self {
|
||||
base_url,
|
||||
http,
|
||||
@@ -95,20 +102,39 @@ impl Client {
|
||||
{
|
||||
h.insert(name, hv);
|
||||
}
|
||||
// Optional internal toggle: send WHAM-FORCE-INTERNAL header when requested.
|
||||
// if matches!(
|
||||
// std::env::var("CODEX_CLOUD_TASKS_FORCE_INTERNAL")
|
||||
// .ok()
|
||||
// .as_deref(),
|
||||
// Some("1") | Some("true") | Some("TRUE")
|
||||
// ) {
|
||||
// if let Ok(name) = HeaderName::from_lowercase(b"wham-force-internal") {
|
||||
// h.insert(name, HeaderValue::from_static("true"));
|
||||
// }
|
||||
// }
|
||||
h
|
||||
}
|
||||
|
||||
async fn exec_request(
|
||||
&self,
|
||||
req: reqwest::RequestBuilder,
|
||||
method: &str,
|
||||
url: &str,
|
||||
) -> Result<(String, String)> {
|
||||
let res = req.send().await?;
|
||||
let status = res.status();
|
||||
let ct = res
|
||||
.headers()
|
||||
.get(CONTENT_TYPE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let body = res.text().await.unwrap_or_default();
|
||||
if !status.is_success() {
|
||||
anyhow::bail!("{method} {url} failed: {status}; content-type={ct}; body={body}");
|
||||
}
|
||||
Ok((body, ct))
|
||||
}
|
||||
|
||||
fn decode_json<T: DeserializeOwned>(&self, url: &str, ct: &str, body: &str) -> Result<T> {
|
||||
match serde_json::from_str::<T>(body) {
|
||||
Ok(v) => Ok(v),
|
||||
Err(e) => {
|
||||
anyhow::bail!("Decode error for {url}: {e}; content-type={ct}; body={body}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn list_tasks(
|
||||
&self,
|
||||
limit: Option<i32>,
|
||||
@@ -117,7 +143,7 @@ impl Client {
|
||||
) -> Result<PaginatedListTaskListItem> {
|
||||
let url = match self.path_style {
|
||||
PathStyle::CodexApi => format!("{}/api/codex/tasks/list", self.base_url),
|
||||
PathStyle::Wham => format!("{}/wham/tasks/list", self.base_url),
|
||||
PathStyle::ChatGptApi => format!("{}/wham/tasks/list", self.base_url),
|
||||
};
|
||||
let req = self.http.get(&url).headers(self.headers());
|
||||
let req = if let Some(lim) = limit {
|
||||
@@ -135,33 +161,8 @@ impl Client {
|
||||
} else {
|
||||
req
|
||||
};
|
||||
let res = req.send().await?;
|
||||
let status = res.status();
|
||||
if !status.is_success() {
|
||||
let ct = res
|
||||
.headers()
|
||||
.get(CONTENT_TYPE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let body = res.text().await.unwrap_or_default();
|
||||
anyhow::bail!("GET {url} failed: {status}; content-type={ct}; body={body}");
|
||||
}
|
||||
// Decode with better diagnostics on failure
|
||||
let ct = res
|
||||
.headers()
|
||||
.get(CONTENT_TYPE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let body = res.text().await.unwrap_or_default();
|
||||
match serde_json::from_str::<PaginatedListTaskListItem>(&body) {
|
||||
Ok(v) => Ok(v),
|
||||
Err(e) => {
|
||||
// Include the full response body to aid debugging rather than truncating.
|
||||
anyhow::bail!("Decode error for {url}: {e}; content-type={ct}; body={body}");
|
||||
}
|
||||
}
|
||||
let (body, ct) = self.exec_request(req, "GET", &url).await?;
|
||||
self.decode_json::<PaginatedListTaskListItem>(&url, &ct, &body)
|
||||
}
|
||||
|
||||
pub async fn get_task_details(&self, task_id: &str) -> Result<CodeTaskDetailsResponse> {
|
||||
@@ -175,33 +176,12 @@ impl Client {
|
||||
) -> Result<(CodeTaskDetailsResponse, String, String)> {
|
||||
let url = match self.path_style {
|
||||
PathStyle::CodexApi => format!("{}/api/codex/tasks/{}", self.base_url, task_id),
|
||||
PathStyle::Wham => format!("{}/wham/tasks/{}", self.base_url, task_id),
|
||||
PathStyle::ChatGptApi => format!("{}/wham/tasks/{}", self.base_url, task_id),
|
||||
};
|
||||
let res = self.http.get(&url).headers(self.headers()).send().await?;
|
||||
let status = res.status();
|
||||
if !status.is_success() {
|
||||
let ct = res
|
||||
.headers()
|
||||
.get(CONTENT_TYPE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let body = res.text().await.unwrap_or_default();
|
||||
anyhow::bail!("GET {url} failed: {status}; content-type={ct}; body={body}");
|
||||
}
|
||||
let ct = res
|
||||
.headers()
|
||||
.get(CONTENT_TYPE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let body = res.text().await.unwrap_or_default();
|
||||
match serde_json::from_str::<CodeTaskDetailsResponse>(&body) {
|
||||
Ok(v) => Ok((v, body, ct)),
|
||||
Err(e) => {
|
||||
anyhow::bail!("Decode error for {url}: {e}; content-type={ct}; body={body}");
|
||||
}
|
||||
}
|
||||
let req = self.http.get(&url).headers(self.headers());
|
||||
let (body, ct) = self.exec_request(req, "GET", &url).await?;
|
||||
let parsed: CodeTaskDetailsResponse = self.decode_json(&url, &ct, &body)?;
|
||||
Ok((parsed, body, ct))
|
||||
}
|
||||
|
||||
/// Create a new task (user turn) by POSTing to the appropriate backend path
|
||||
@@ -209,27 +189,15 @@ impl Client {
|
||||
pub async fn create_task(&self, request_body: serde_json::Value) -> Result<String> {
|
||||
let url = match self.path_style {
|
||||
PathStyle::CodexApi => format!("{}/api/codex/tasks", self.base_url),
|
||||
PathStyle::Wham => format!("{}/wham/tasks", self.base_url),
|
||||
PathStyle::ChatGptApi => format!("{}/wham/tasks", self.base_url),
|
||||
};
|
||||
let res = self
|
||||
let req = self
|
||||
.http
|
||||
.post(&url)
|
||||
.headers(self.headers())
|
||||
.header(CONTENT_TYPE, HeaderValue::from_static("application/json"))
|
||||
.json(&request_body)
|
||||
.send()
|
||||
.await?;
|
||||
let status = res.status();
|
||||
let ct = res
|
||||
.headers()
|
||||
.get(CONTENT_TYPE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let body = res.text().await.unwrap_or_default();
|
||||
if !status.is_success() {
|
||||
anyhow::bail!("POST {url} failed: {status}; content-type={ct}; body={body}");
|
||||
}
|
||||
.json(&request_body);
|
||||
let (body, ct) = self.exec_request(req, "POST", &url).await?;
|
||||
// Extract id from JSON: prefer `task.id`; fallback to top-level `id` when present.
|
||||
match serde_json::from_str::<serde_json::Value>(&body) {
|
||||
Ok(v) => {
|
||||
@@ -247,9 +215,7 @@ impl Client {
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
anyhow::bail!("Decode error for {url}: {e}; content-type={ct}; body={body}");
|
||||
}
|
||||
Err(e) => anyhow::bail!("Decode error for {url}: {e}; content-type={ct}; body={body}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,8 +14,6 @@ pub trait CodeTaskDetailsResponseExt {
|
||||
fn user_text_prompt(&self) -> Option<String>;
|
||||
/// Extract an assistant error message (if the turn failed and provided one).
|
||||
fn assistant_error_message(&self) -> Option<String>;
|
||||
/// Best-effort: extract a single file old/new path for header synthesis when only hunk bodies are provided.
|
||||
fn single_file_paths(&self) -> Option<(String, String)>;
|
||||
}
|
||||
impl CodeTaskDetailsResponseExt for CodeTaskDetailsResponse {
|
||||
fn unified_diff(&self) -> Option<String> {
|
||||
@@ -108,7 +106,11 @@ impl CodeTaskDetailsResponseExt for CodeTaskDetailsResponse {
|
||||
}
|
||||
}
|
||||
}
|
||||
if parts.is_empty() { None } else { Some(parts.join("\n\n")) }
|
||||
if parts.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(parts.join("\n\n"))
|
||||
}
|
||||
}
|
||||
|
||||
fn assistant_error_message(&self) -> Option<String> {
|
||||
@@ -126,137 +128,7 @@ impl CodeTaskDetailsResponseExt for CodeTaskDetailsResponse {
|
||||
Some(format!("{code}: {message}"))
|
||||
}
|
||||
}
|
||||
fn single_file_paths(&self) -> Option<(String, String)> {
|
||||
fn try_from_items(items: &Vec<serde_json::Value>) -> Option<(String, String)> {
|
||||
use serde_json::Value;
|
||||
for item in items {
|
||||
if let Some(obj) = item.as_object() {
|
||||
if let Some(p) = obj.get("path").and_then(Value::as_str) {
|
||||
let p = p.to_string();
|
||||
return Some((p.clone(), p));
|
||||
}
|
||||
let old = obj.get("old_path").and_then(Value::as_str);
|
||||
let newp = obj.get("new_path").and_then(Value::as_str);
|
||||
if let (Some(o), Some(n)) = (old, newp) {
|
||||
return Some((o.to_string(), n.to_string()));
|
||||
}
|
||||
if let Some(od) = obj.get("output_diff").and_then(Value::as_object) {
|
||||
if let Some(fm) = od.get("files_modified") {
|
||||
if let Some(map) = fm.as_object()
|
||||
&& map.len() == 1
|
||||
&& let Some((k, _)) = map.iter().next()
|
||||
{
|
||||
let p = k.to_string();
|
||||
return Some((p.clone(), p));
|
||||
} else if let Some(arr) = fm.as_array()
|
||||
&& arr.len() == 1
|
||||
{
|
||||
let el = &arr[0];
|
||||
if let Some(p) = el.as_str() {
|
||||
let p = p.to_string();
|
||||
return Some((p.clone(), p));
|
||||
}
|
||||
if let Some(o) = el.as_object() {
|
||||
let path = o.get("path").and_then(Value::as_str);
|
||||
let oldp = o.get("old_path").and_then(Value::as_str);
|
||||
let newp = o.get("new_path").and_then(Value::as_str);
|
||||
if let Some(p) = path {
|
||||
let p = p.to_string();
|
||||
return Some((p.clone(), p));
|
||||
}
|
||||
if let (Some(o1), Some(n1)) = (oldp, newp) {
|
||||
return Some((o1.to_string(), n1.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(p) = od.get("path").and_then(Value::as_str) {
|
||||
let p = p.to_string();
|
||||
return Some((p.clone(), p));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
let candidates: [&Option<std::collections::HashMap<String, serde_json::Value>>; 2] =
|
||||
[&self.current_diff_task_turn, &self.current_assistant_turn];
|
||||
for map in candidates {
|
||||
if let Some(m) = map.as_ref()
|
||||
&& let Some(items) = m.get("output_items").and_then(serde_json::Value::as_array)
|
||||
&& let Some(p) = try_from_items(items)
|
||||
{
|
||||
return Some(p);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Best-effort extraction of a list of (old_path, new_path) pairs for files involved
|
||||
/// in the current task's diff output. For entries where only a single `path` is present,
|
||||
/// the pair will be (path, path).
|
||||
pub fn extract_file_paths_list(details: &CodeTaskDetailsResponse) -> Vec<(String, String)> {
|
||||
use serde_json::Value;
|
||||
fn push_from_items(out: &mut Vec<(String, String)>, items: &Vec<Value>) {
|
||||
for item in items {
|
||||
if let Some(obj) = item.as_object() {
|
||||
if let Some(p) = obj.get("path").and_then(Value::as_str) {
|
||||
let p = p.to_string();
|
||||
out.push((p.clone(), p));
|
||||
continue;
|
||||
}
|
||||
let old = obj.get("old_path").and_then(Value::as_str);
|
||||
let newp = obj.get("new_path").and_then(Value::as_str);
|
||||
if let (Some(o), Some(n)) = (old, newp) {
|
||||
out.push((o.to_string(), n.to_string()));
|
||||
continue;
|
||||
}
|
||||
if let Some(od) = obj.get("output_diff").and_then(Value::as_object) {
|
||||
if let Some(fm) = od.get("files_modified") {
|
||||
if let Some(map) = fm.as_object() {
|
||||
for (k, _v) in map {
|
||||
let p = k.to_string();
|
||||
out.push((p.clone(), p));
|
||||
}
|
||||
} else if let Some(arr) = fm.as_array() {
|
||||
for el in arr {
|
||||
if let Some(p) = el.as_str() {
|
||||
let p = p.to_string();
|
||||
out.push((p.clone(), p));
|
||||
} else if let Some(o) = el.as_object() {
|
||||
let path = o.get("path").and_then(Value::as_str);
|
||||
let oldp = o.get("old_path").and_then(Value::as_str);
|
||||
let newp = o.get("new_path").and_then(Value::as_str);
|
||||
if let Some(p) = path {
|
||||
let p = p.to_string();
|
||||
out.push((p.clone(), p));
|
||||
} else if let (Some(o1), Some(n1)) = (oldp, newp) {
|
||||
out.push((o1.to_string(), n1.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(p) = od.get("path").and_then(Value::as_str) {
|
||||
let p = p.to_string();
|
||||
out.push((p.clone(), p));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut out: Vec<(String, String)> = Vec::new();
|
||||
let candidates: [&Option<std::collections::HashMap<String, Value>>; 2] = [
|
||||
&details.current_diff_task_turn,
|
||||
&details.current_assistant_turn,
|
||||
];
|
||||
for map in candidates {
|
||||
if let Some(m) = map.as_ref()
|
||||
&& let Some(items) = m.get("output_items").and_then(Value::as_array)
|
||||
{
|
||||
push_from_items(&mut out, items);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
// Removed unused helpers `single_file_paths` and `extract_file_paths_list` to reduce
|
||||
// surface area; reintroduce as needed near call sites.
|
||||
|
||||
@@ -16,6 +16,7 @@ reqwest = { version = "0.12", features = ["json", "stream"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
codex-git-apply = { path = "../git-apply" }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
||||
@@ -56,46 +56,24 @@ pub async fn apply_diff_from_task(
|
||||
}
|
||||
|
||||
async fn apply_diff(diff: &str, cwd: Option<PathBuf>) -> anyhow::Result<()> {
|
||||
let mut cmd = tokio::process::Command::new("git");
|
||||
if let Some(cwd) = cwd {
|
||||
cmd.current_dir(cwd);
|
||||
}
|
||||
let toplevel_output = cmd
|
||||
.args(vec!["rev-parse", "--show-toplevel"])
|
||||
.output()
|
||||
.await?;
|
||||
|
||||
if !toplevel_output.status.success() {
|
||||
anyhow::bail!("apply must be run from a git repository.");
|
||||
}
|
||||
|
||||
let repo_root = String::from_utf8(toplevel_output.stdout)?
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
let mut git_apply_cmd = tokio::process::Command::new("git")
|
||||
.args(vec!["apply", "--3way"])
|
||||
.current_dir(&repo_root)
|
||||
.stdin(std::process::Stdio::piped())
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.spawn()?;
|
||||
|
||||
if let Some(mut stdin) = git_apply_cmd.stdin.take() {
|
||||
tokio::io::AsyncWriteExt::write_all(&mut stdin, diff.as_bytes()).await?;
|
||||
drop(stdin);
|
||||
}
|
||||
|
||||
let output = git_apply_cmd.wait_with_output().await?;
|
||||
|
||||
if !output.status.success() {
|
||||
let cwd = cwd.unwrap_or(std::env::current_dir().unwrap_or_else(|_| std::env::temp_dir()));
|
||||
let req = codex_git_apply::ApplyGitRequest {
|
||||
cwd,
|
||||
diff: diff.to_string(),
|
||||
revert: false,
|
||||
preflight: false,
|
||||
};
|
||||
let res = codex_git_apply::apply_git_patch(&req)?;
|
||||
if res.exit_code != 0 {
|
||||
anyhow::bail!(
|
||||
"Git apply failed with status {}: {}",
|
||||
output.status,
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
"Git apply failed (applied={}, skipped={}, conflicts={})\nstdout:\n{}\nstderr:\n{}",
|
||||
res.applied_paths.len(),
|
||||
res.skipped_paths.len(),
|
||||
res.conflicted_paths.len(),
|
||||
res.stdout,
|
||||
res.stderr
|
||||
);
|
||||
}
|
||||
|
||||
println!("Successfully applied diff");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -26,6 +26,4 @@ serde_json = "1"
|
||||
thiserror = "2.0.12"
|
||||
tokio = { version = "1", features = ["macros", "rt-multi-thread"], optional = true }
|
||||
codex-backend-client = { path = "../backend-client", optional = true }
|
||||
regex = "1"
|
||||
tempfile = "3"
|
||||
once_cell = "1"
|
||||
codex-git-apply = { path = "../git-apply" }
|
||||
|
||||
@@ -13,9 +13,6 @@ pub enum Error {
|
||||
Http(String),
|
||||
#[error("io error: {0}")]
|
||||
Io(String),
|
||||
/// Expected condition: the task has no diff available yet (e.g., still in progress).
|
||||
#[error("no diff available yet")]
|
||||
NoDiffYet,
|
||||
#[error("{0}")]
|
||||
Msg(String),
|
||||
}
|
||||
@@ -86,11 +83,14 @@ pub struct TaskText {
|
||||
#[async_trait::async_trait]
|
||||
pub trait CloudBackend: Send + Sync {
|
||||
async fn list_tasks(&self, env: Option<&str>) -> Result<Vec<TaskSummary>>;
|
||||
async fn get_task_diff(&self, id: TaskId) -> Result<String>;
|
||||
async fn get_task_diff(&self, id: TaskId) -> Result<Option<String>>;
|
||||
/// Return assistant output messages (no diff) when available.
|
||||
async fn get_task_messages(&self, id: TaskId) -> Result<Vec<String>>;
|
||||
/// Return the creating prompt and assistant messages (when available).
|
||||
async fn get_task_text(&self, id: TaskId) -> Result<TaskText>;
|
||||
/// Dry-run apply (preflight) that validates whether the patch would apply cleanly.
|
||||
/// Never modifies the working tree.
|
||||
async fn apply_task_preflight(&self, id: TaskId) -> Result<ApplyOutcome>;
|
||||
async fn apply_task(&self, id: TaskId) -> Result<ApplyOutcome>;
|
||||
async fn create_task(
|
||||
&self,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
use crate::ApplyOutcome;
|
||||
use crate::api::TaskText;
|
||||
use crate::ApplyStatus;
|
||||
use crate::CloudBackend;
|
||||
use crate::DiffSummary;
|
||||
@@ -8,6 +7,7 @@ use crate::Result;
|
||||
use crate::TaskId;
|
||||
use crate::TaskStatus;
|
||||
use crate::TaskSummary;
|
||||
use crate::api::TaskText;
|
||||
use chrono::DateTime;
|
||||
use chrono::Utc;
|
||||
|
||||
@@ -87,7 +87,7 @@ impl CloudBackend for HttpClient {
|
||||
Ok(tasks)
|
||||
}
|
||||
|
||||
async fn get_task_diff(&self, _id: TaskId) -> Result<String> {
|
||||
async fn get_task_diff(&self, _id: TaskId) -> Result<Option<String>> {
|
||||
let id = _id.0;
|
||||
let (details, body, ct) = self
|
||||
.backend
|
||||
@@ -95,12 +95,12 @@ impl CloudBackend for HttpClient {
|
||||
.await
|
||||
.map_err(|e| Error::Http(format!("get_task_details failed: {e}")))?;
|
||||
if let Some(diff) = details.unified_diff() {
|
||||
return Ok(diff);
|
||||
return Ok(Some(diff));
|
||||
}
|
||||
// No diff yet (pending or non-diff task). Return a structured error so UI can render cleanly.
|
||||
// Keep a concise body tail in logs if needed by callers.
|
||||
let _ = (body, ct); // silence unused if logging is disabled at callsite
|
||||
Err(Error::NoDiffYet)
|
||||
// No diff yet (pending or non-diff task).
|
||||
// Keep variables bound for potential future logging.
|
||||
let _ = (body, ct);
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn get_task_messages(&self, _id: TaskId) -> Result<Vec<String>> {
|
||||
@@ -112,49 +112,7 @@ impl CloudBackend for HttpClient {
|
||||
.map_err(|e| Error::Http(format!("get_task_details failed: {e}")))?;
|
||||
let mut msgs = details.assistant_text_messages();
|
||||
if msgs.is_empty() {
|
||||
// Fallback: some pending tasks expose only worklog messages; parse from raw body.
|
||||
if let Ok(full) = serde_json::from_str::<serde_json::Value>(&body) {
|
||||
// worklog.messages[*] where author.role == "assistant" → content.parts[*].text
|
||||
if 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() {
|
||||
// Shape: content { content_type: "text", parts: ["..."] }
|
||||
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.extend(extract_assistant_messages_from_body(&body));
|
||||
}
|
||||
if !msgs.is_empty() {
|
||||
return Ok(msgs);
|
||||
@@ -182,44 +140,8 @@ impl CloudBackend for HttpClient {
|
||||
.map_err(|e| Error::Http(format!("get_task_details failed: {e}")))?;
|
||||
let prompt = details.user_text_prompt();
|
||||
let mut messages = details.assistant_text_messages();
|
||||
if messages.is_empty()
|
||||
&& 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() {
|
||||
messages.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())
|
||||
{
|
||||
messages.push(txt.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if messages.is_empty() {
|
||||
messages.extend(extract_assistant_messages_from_body(&body));
|
||||
}
|
||||
Ok(TaskText { prompt, messages })
|
||||
}
|
||||
@@ -252,12 +174,13 @@ impl CloudBackend for HttpClient {
|
||||
}
|
||||
|
||||
// Run the new Git apply engine
|
||||
let req = crate::git_apply::ApplyGitRequest {
|
||||
let req = codex_git_apply::ApplyGitRequest {
|
||||
cwd: std::env::current_dir().unwrap_or_else(|_| std::env::temp_dir()),
|
||||
diff: diff.clone(),
|
||||
revert: false,
|
||||
preflight: false,
|
||||
};
|
||||
let r = crate::git_apply::apply_git_patch(&req)
|
||||
let r = codex_git_apply::apply_git_patch(&req)
|
||||
.map_err(|e| Error::Io(format!("git apply failed to run: {e}")))?;
|
||||
|
||||
let status = if r.exit_code == 0 {
|
||||
@@ -352,6 +275,103 @@ impl CloudBackend for HttpClient {
|
||||
})
|
||||
}
|
||||
|
||||
async fn apply_task_preflight(&self, _id: TaskId) -> Result<ApplyOutcome> {
|
||||
let id = _id.0;
|
||||
// Fetch diff fresh and apply locally via git (unified diffs).
|
||||
let details = self
|
||||
.backend
|
||||
.get_task_details(&id)
|
||||
.await
|
||||
.map_err(|e| Error::Http(format!("get_task_details failed: {e}")))?;
|
||||
let diff = details
|
||||
.unified_diff()
|
||||
.ok_or_else(|| Error::Msg(format!("No diff available for task {id}")))?;
|
||||
// Enforce unified diff format only
|
||||
if !is_unified_diff(&diff) {
|
||||
let summary = summarize_patch_for_logging(&diff);
|
||||
append_error_log(&format!(
|
||||
"apply_error: id={id} 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(),
|
||||
});
|
||||
}
|
||||
|
||||
// Preflight: run with --check (no changes)
|
||||
let req = codex_git_apply::ApplyGitRequest {
|
||||
cwd: std::env::current_dir().unwrap_or_else(|_| std::env::temp_dir()),
|
||||
diff: diff.clone(),
|
||||
revert: false,
|
||||
preflight: true,
|
||||
};
|
||||
let r = codex_git_apply::apply_git_patch(&req)
|
||||
.map_err(|e| Error::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 message = 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()
|
||||
),
|
||||
};
|
||||
|
||||
if !matches!(status, ApplyStatus::Success) {
|
||||
let mut log = String::new();
|
||||
let summary = summarize_patch_for_logging(&diff);
|
||||
use std::fmt::Write as _;
|
||||
let _ = writeln!(
|
||||
&mut log,
|
||||
"apply_preflight_result: id={} status={:?} applied={} skipped={} conflicts={} cmd={}",
|
||||
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: false,
|
||||
status,
|
||||
message,
|
||||
skipped_paths: r.skipped_paths,
|
||||
conflict_paths: r.conflicted_paths,
|
||||
})
|
||||
}
|
||||
|
||||
async fn create_task(
|
||||
&self,
|
||||
env_id: &str,
|
||||
@@ -408,6 +428,51 @@ impl CloudBackend for HttpClient {
|
||||
}
|
||||
}
|
||||
|
||||
/// Best-effort extraction of assistant text messages from a raw `get_task_details` body.
|
||||
/// Falls back to worklog messages when structured turns are not present.
|
||||
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 map_task_list_item_to_summary(src: backend::TaskListItem) -> TaskSummary {
|
||||
fn env_label_from_status_display(v: Option<&HashMap<String, Value>>) -> Option<String> {
|
||||
let obj = v?;
|
||||
|
||||
@@ -26,6 +26,4 @@ pub use mock::MockClient;
|
||||
#[cfg(feature = "online")]
|
||||
pub use http::HttpClient;
|
||||
|
||||
// Reusable apply engine (git apply runner and helpers)
|
||||
// Legacy engine remains until migration completes. New engine lives in git_apply.
|
||||
mod git_apply;
|
||||
// Reusable apply engine now lives in the shared crate `codex-git-apply`.
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
use crate::ApplyOutcome;
|
||||
use crate::api::TaskText;
|
||||
use crate::CloudBackend;
|
||||
use crate::DiffSummary;
|
||||
use crate::Result;
|
||||
use crate::TaskId;
|
||||
use crate::TaskStatus;
|
||||
use crate::TaskSummary;
|
||||
use crate::api::TaskText;
|
||||
use chrono::Utc;
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
@@ -56,8 +56,8 @@ impl CloudBackend for MockClient {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
async fn get_task_diff(&self, id: TaskId) -> Result<String> {
|
||||
Ok(mock_diff_for(&id))
|
||||
async fn get_task_diff(&self, id: TaskId) -> Result<Option<String>> {
|
||||
Ok(Some(mock_diff_for(&id)))
|
||||
}
|
||||
|
||||
async fn get_task_messages(&self, _id: TaskId) -> Result<Vec<String>> {
|
||||
@@ -83,6 +83,16 @@ impl CloudBackend for MockClient {
|
||||
})
|
||||
}
|
||||
|
||||
async fn apply_task_preflight(&self, id: TaskId) -> Result<ApplyOutcome> {
|
||||
Ok(ApplyOutcome {
|
||||
applied: false,
|
||||
status: crate::ApplyStatus::Success,
|
||||
message: format!("Preflight passed for task {} (mock)", id.0),
|
||||
skipped_paths: Vec::new(),
|
||||
conflict_paths: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn create_task(
|
||||
&self,
|
||||
env_id: &str,
|
||||
|
||||
@@ -221,7 +221,10 @@ mod tests {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
async fn get_task_diff(&self, _id: TaskId) -> codex_cloud_tasks_client::Result<String> {
|
||||
async fn get_task_diff(
|
||||
&self,
|
||||
_id: TaskId,
|
||||
) -> codex_cloud_tasks_client::Result<Option<String>> {
|
||||
Err(codex_cloud_tasks_client::Error::Unimplemented(
|
||||
"not used in test",
|
||||
))
|
||||
@@ -237,7 +240,10 @@ mod tests {
|
||||
&self,
|
||||
_id: TaskId,
|
||||
) -> codex_cloud_tasks_client::Result<codex_cloud_tasks_client::TaskText> {
|
||||
Ok(codex_cloud_tasks_client::TaskText { prompt: Some("Example prompt".to_string()), messages: Vec::new() })
|
||||
Ok(codex_cloud_tasks_client::TaskText {
|
||||
prompt: Some("Example prompt".to_string()),
|
||||
messages: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn apply_task(
|
||||
@@ -249,6 +255,15 @@ mod tests {
|
||||
))
|
||||
}
|
||||
|
||||
async fn apply_task_preflight(
|
||||
&self,
|
||||
_id: TaskId,
|
||||
) -> codex_cloud_tasks_client::Result<codex_cloud_tasks_client::ApplyOutcome> {
|
||||
Err(codex_cloud_tasks_client::Error::Unimplemented(
|
||||
"not used in test",
|
||||
))
|
||||
}
|
||||
|
||||
async fn create_task(
|
||||
&self,
|
||||
_env_id: &str,
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used)]
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use base64::Engine;
|
||||
use codex_backend_client::Client as BackendClient;
|
||||
use codex_cloud_tasks::util::extract_chatgpt_account_id;
|
||||
use codex_cloud_tasks::util::normalize_base_url;
|
||||
use codex_core::config::find_codex_home;
|
||||
use codex_core::default_client::get_codex_user_agent;
|
||||
use codex_login::AuthManager;
|
||||
use codex_login::AuthMode;
|
||||
use std::time::Duration;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
// Base URL (default to ChatGPT backend API)
|
||||
let base_url = std::env::var("CODEX_CLOUD_TASKS_BASE_URL")
|
||||
// Base URL (default to ChatGPT backend API) and normalize to canonical form
|
||||
let raw_base = std::env::var("CODEX_CLOUD_TASKS_BASE_URL")
|
||||
.unwrap_or_else(|_| "https://chatgpt.com/backend-api".to_string());
|
||||
let base_url = normalize_base_url(&raw_base);
|
||||
println!("base_url: {base_url}");
|
||||
let path_style = if base_url.contains("/backend-api") {
|
||||
"wham"
|
||||
@@ -102,34 +103,9 @@ async fn main() -> anyhow::Result<()> {
|
||||
for item in list.items.iter().take(5) {
|
||||
println!("- {} — {}", item.id, item.title);
|
||||
}
|
||||
// Print the full response object for debugging/inspection.
|
||||
match serde_json::to_string_pretty(&list) {
|
||||
Ok(json) => {
|
||||
println!("\nfull response object (pretty JSON):\n{json}");
|
||||
}
|
||||
Err(e) => {
|
||||
println!("failed to serialize response to JSON: {e}");
|
||||
}
|
||||
}
|
||||
// Keep output concise; omit full JSON payload to stay readable.
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn extract_chatgpt_account_id(token: &str) -> Option<String> {
|
||||
// JWT: header.payload.signature
|
||||
let mut parts = token.split('.');
|
||||
let (_h, payload_b64, _s) = match (parts.next(), parts.next(), parts.next()) {
|
||||
(Some(h), Some(p), Some(s)) if !h.is_empty() && !p.is_empty() && !s.is_empty() => (h, p, s),
|
||||
_ => return None,
|
||||
};
|
||||
let payload_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
|
||||
.decode(payload_b64)
|
||||
.ok()?;
|
||||
let v: serde_json::Value = serde_json::from_slice(&payload_bytes).ok()?;
|
||||
v.get("https://api.openai.com/auth")
|
||||
.and_then(|auth| auth.get("chatgpt_account_id"))
|
||||
.and_then(|id| id.as_str())
|
||||
.map(|s| s.to_string())
|
||||
}
|
||||
|
||||
@@ -6,28 +6,17 @@ pub mod env_detect;
|
||||
mod new_task;
|
||||
pub mod scrollable_diff;
|
||||
mod ui;
|
||||
pub mod util;
|
||||
pub use cli::Cli;
|
||||
|
||||
use base64::Engine as _;
|
||||
use chrono::Utc;
|
||||
use std::fs::OpenOptions;
|
||||
use std::io::IsTerminal;
|
||||
use std::io::Write as _;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
use tracing::info;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
use util::append_error_log;
|
||||
|
||||
pub(crate) fn append_error_log(message: impl AsRef<str>) {
|
||||
let ts = Utc::now().to_rfc3339();
|
||||
if let Ok(mut f) = OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open("error.log")
|
||||
{
|
||||
let _ = writeln!(f, "[{ts}] {}", message.as_ref());
|
||||
}
|
||||
}
|
||||
// logging helper lives in util module
|
||||
|
||||
// (no standalone patch summarizer needed – UI displays raw diffs)
|
||||
|
||||
@@ -98,7 +87,7 @@ pub async fn run_main(_cli: Cli, _codex_linux_sandbox_exe: Option<PathBuf>) -> a
|
||||
http = http.with_bearer_token(t.clone());
|
||||
if let Some(acc) = auth
|
||||
.get_account_id()
|
||||
.or_else(|| extract_chatgpt_account_id(&t))
|
||||
.or_else(|| util::extract_chatgpt_account_id(&t))
|
||||
{
|
||||
append_error_log(format!("auth: set ChatGPT-Account-Id header: {acc}"));
|
||||
http = http.with_chatgpt_account_id(acc);
|
||||
@@ -201,50 +190,11 @@ pub async fn run_main(_cli: Cli, _codex_linux_sandbox_exe: Option<PathBuf>) -> a
|
||||
{
|
||||
let tx2 = tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut base_url = std::env::var("CODEX_CLOUD_TASKS_BASE_URL")
|
||||
.unwrap_or_else(|_| "https://chatgpt.com/backend-api".to_string());
|
||||
while base_url.ends_with('/') {
|
||||
base_url.pop();
|
||||
}
|
||||
if (base_url.starts_with("https://chatgpt.com")
|
||||
|| base_url.starts_with("https://chat.openai.com"))
|
||||
&& !base_url.contains("/backend-api")
|
||||
{
|
||||
base_url = format!("{base_url}/backend-api");
|
||||
}
|
||||
let ua =
|
||||
codex_core::default_client::get_codex_user_agent(Some("codex_cloud_tasks_tui"));
|
||||
let mut headers = reqwest::header::HeaderMap::new();
|
||||
headers.insert(
|
||||
reqwest::header::USER_AGENT,
|
||||
reqwest::header::HeaderValue::from_str(&ua)
|
||||
.unwrap_or(reqwest::header::HeaderValue::from_static("codex-cli")),
|
||||
let base_url = util::normalize_base_url(
|
||||
&std::env::var("CODEX_CLOUD_TASKS_BASE_URL")
|
||||
.unwrap_or_else(|_| "https://chatgpt.com/backend-api".to_string()),
|
||||
);
|
||||
if let Ok(home) = codex_core::config::find_codex_home() {
|
||||
let am = codex_login::AuthManager::new(
|
||||
home,
|
||||
codex_login::AuthMode::ChatGPT,
|
||||
"codex_cloud_tasks_tui".to_string(),
|
||||
);
|
||||
if let Some(auth) = am.auth()
|
||||
&& let Ok(tok) = auth.get_token().await
|
||||
&& !tok.is_empty()
|
||||
{
|
||||
let v = format!("Bearer {tok}");
|
||||
if let Ok(hv) = reqwest::header::HeaderValue::from_str(&v) {
|
||||
headers.insert(reqwest::header::AUTHORIZATION, hv);
|
||||
}
|
||||
if let Some(acc) = auth
|
||||
.get_account_id()
|
||||
.or_else(|| extract_chatgpt_account_id(&tok))
|
||||
&& let Ok(name) =
|
||||
reqwest::header::HeaderName::from_bytes(b"ChatGPT-Account-Id")
|
||||
&& let Ok(hv) = reqwest::header::HeaderValue::from_str(&acc)
|
||||
{
|
||||
headers.insert(name, hv);
|
||||
}
|
||||
}
|
||||
}
|
||||
let headers = util::build_chatgpt_headers().await;
|
||||
let res = crate::env_detect::list_environments(&base_url, &headers).await;
|
||||
let _ = tx2.send(app::AppEvent::EnvironmentsLoaded(res));
|
||||
});
|
||||
@@ -255,54 +205,12 @@ pub async fn run_main(_cli: Cli, _codex_linux_sandbox_exe: Option<PathBuf>) -> a
|
||||
{
|
||||
let tx2 = tx.clone();
|
||||
tokio::spawn(async move {
|
||||
// Normalize base URL like envcheck.rs does
|
||||
let mut base_url = std::env::var("CODEX_CLOUD_TASKS_BASE_URL")
|
||||
.unwrap_or_else(|_| "https://chatgpt.com/backend-api".to_string());
|
||||
while base_url.ends_with('/') {
|
||||
base_url.pop();
|
||||
}
|
||||
if (base_url.starts_with("https://chatgpt.com")
|
||||
|| base_url.starts_with("https://chat.openai.com"))
|
||||
&& !base_url.contains("/backend-api")
|
||||
{
|
||||
base_url = format!("{base_url}/backend-api");
|
||||
}
|
||||
|
||||
// Build headers: UA + ChatGPT auth if available
|
||||
let ua =
|
||||
codex_core::default_client::get_codex_user_agent(Some("codex_cloud_tasks_tui"));
|
||||
let mut headers = reqwest::header::HeaderMap::new();
|
||||
headers.insert(
|
||||
reqwest::header::USER_AGENT,
|
||||
reqwest::header::HeaderValue::from_str(&ua)
|
||||
.unwrap_or(reqwest::header::HeaderValue::from_static("codex-cli")),
|
||||
let base_url = util::normalize_base_url(
|
||||
&std::env::var("CODEX_CLOUD_TASKS_BASE_URL")
|
||||
.unwrap_or_else(|_| "https://chatgpt.com/backend-api".to_string()),
|
||||
);
|
||||
if let Ok(home) = codex_core::config::find_codex_home() {
|
||||
let am = codex_login::AuthManager::new(
|
||||
home,
|
||||
codex_login::AuthMode::ChatGPT,
|
||||
"codex_cloud_tasks_tui".to_string(),
|
||||
);
|
||||
if let Some(auth) = am.auth()
|
||||
&& let Ok(token) = auth.get_token().await
|
||||
&& !token.is_empty()
|
||||
{
|
||||
if let Ok(hv) =
|
||||
reqwest::header::HeaderValue::from_str(&format!("Bearer {token}"))
|
||||
{
|
||||
headers.insert(reqwest::header::AUTHORIZATION, hv);
|
||||
}
|
||||
if let Some(account_id) = auth
|
||||
.get_account_id()
|
||||
.or_else(|| extract_chatgpt_account_id(&token))
|
||||
&& let Ok(name) =
|
||||
reqwest::header::HeaderName::from_bytes(b"ChatGPT-Account-Id")
|
||||
&& let Ok(hv) = reqwest::header::HeaderValue::from_str(&account_id)
|
||||
{
|
||||
headers.insert(name, hv);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Build headers: UA + ChatGPT auth if available
|
||||
let headers = util::build_chatgpt_headers().await;
|
||||
|
||||
// Run autodetect. If it fails, we keep using "All".
|
||||
let res = crate::env_detect::autodetect_environment_id(&base_url, &headers, None).await;
|
||||
@@ -512,34 +420,11 @@ pub async fn run_main(_cli: Cli, _codex_linux_sandbox_exe: Option<PathBuf>) -> a
|
||||
app.env_loading = true;
|
||||
let tx3 = tx.clone();
|
||||
tokio::spawn(async move {
|
||||
// Build headers (UA + ChatGPT token + account id) like elsewhere
|
||||
let mut base_url = std::env::var("CODEX_CLOUD_TASKS_BASE_URL").unwrap_or_else(|_| "https://chatgpt.com/backend-api".to_string());
|
||||
while base_url.ends_with('/') { base_url.pop(); }
|
||||
if (base_url.starts_with("https://chatgpt.com") || base_url.starts_with("https://chat.openai.com")) && !base_url.contains("/backend-api") {
|
||||
base_url = format!("{base_url}/backend-api");
|
||||
}
|
||||
let ua = codex_core::default_client::get_codex_user_agent(Some("codex_cloud_tasks_tui"));
|
||||
let mut headers = reqwest::header::HeaderMap::new();
|
||||
headers.insert(reqwest::header::USER_AGENT, reqwest::header::HeaderValue::from_str(&ua).unwrap_or(reqwest::header::HeaderValue::from_static("codex-cli")));
|
||||
if let Ok(home) = codex_core::config::find_codex_home() {
|
||||
let am = codex_login::AuthManager::new(
|
||||
home,
|
||||
codex_login::AuthMode::ChatGPT,
|
||||
"codex_cloud_tasks_tui".to_string(),
|
||||
);
|
||||
if let Some(auth) = am.auth()
|
||||
&& let Ok(tok) = auth.get_token().await
|
||||
&& !tok.is_empty()
|
||||
{
|
||||
let v = format!("Bearer {tok}");
|
||||
if let Ok(hv) = reqwest::header::HeaderValue::from_str(&v) { headers.insert(reqwest::header::AUTHORIZATION, hv); }
|
||||
if let Some(acc) = auth.get_account_id().or_else(|| extract_chatgpt_account_id(&tok))
|
||||
&& let Ok(name) = reqwest::header::HeaderName::from_bytes(b"ChatGPT-Account-Id")
|
||||
&& let Ok(hv) = reqwest::header::HeaderValue::from_str(&acc) {
|
||||
headers.insert(name, hv);
|
||||
}
|
||||
}
|
||||
}
|
||||
let base_url = crate::util::normalize_base_url(
|
||||
&std::env::var("CODEX_CLOUD_TASKS_BASE_URL")
|
||||
.unwrap_or_else(|_| "https://chatgpt.com/backend-api".to_string()),
|
||||
);
|
||||
let headers = crate::util::build_chatgpt_headers().await;
|
||||
let res = crate::env_detect::list_environments(&base_url, &headers).await;
|
||||
let _ = tx3.send(app::AppEvent::EnvironmentsLoaded(res));
|
||||
});
|
||||
@@ -673,38 +558,13 @@ pub async fn run_main(_cli: Cli, _codex_linux_sandbox_exe: Option<PathBuf>) -> a
|
||||
}
|
||||
needs_redraw = true;
|
||||
if should_fetch {
|
||||
let tx2 = tx.clone();
|
||||
tokio::spawn(async move {
|
||||
// Build headers (UA + ChatGPT token + account id)
|
||||
let mut base_url = std::env::var("CODEX_CLOUD_TASKS_BASE_URL")
|
||||
.unwrap_or_else(|_| "https://chatgpt.com/backend-api".to_string());
|
||||
while base_url.ends_with('/') { base_url.pop(); }
|
||||
if (base_url.starts_with("https://chatgpt.com") || base_url.starts_with("https://chat.openai.com")) && !base_url.contains("/backend-api") {
|
||||
base_url = format!("{base_url}/backend-api");
|
||||
}
|
||||
let ua = codex_core::default_client::get_codex_user_agent(Some("codex_cloud_tasks_tui"));
|
||||
let mut headers = reqwest::header::HeaderMap::new();
|
||||
headers.insert(reqwest::header::USER_AGENT, reqwest::header::HeaderValue::from_str(&ua).unwrap_or(reqwest::header::HeaderValue::from_static("codex-cli")));
|
||||
if let Ok(home) = codex_core::config::find_codex_home() {
|
||||
let am = codex_login::AuthManager::new(
|
||||
home,
|
||||
codex_login::AuthMode::ChatGPT,
|
||||
"codex_cloud_tasks_tui".to_string(),
|
||||
);
|
||||
if let Some(auth) = am.auth()
|
||||
&& let Ok(tok) = auth.get_token().await && !tok.is_empty() {
|
||||
let v = format!("Bearer {tok}");
|
||||
if let Ok(hv) = reqwest::header::HeaderValue::from_str(&v) { headers.insert(reqwest::header::AUTHORIZATION, hv); }
|
||||
if let Some(acc) = auth.get_account_id().or_else(|| extract_chatgpt_account_id(&tok))
|
||||
&& let Ok(name) = reqwest::header::HeaderName::from_bytes(b"ChatGPT-Account-Id")
|
||||
&& let Ok(hv) = reqwest::header::HeaderValue::from_str(&acc) {
|
||||
headers.insert(name, hv);
|
||||
}
|
||||
}
|
||||
}
|
||||
let res = crate::env_detect::list_environments(&base_url, &headers).await;
|
||||
let _ = tx2.send(app::AppEvent::EnvironmentsLoaded(res));
|
||||
});
|
||||
let tx2 = tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let base_url = crate::util::normalize_base_url(&std::env::var("CODEX_CLOUD_TASKS_BASE_URL").unwrap_or_else(|_| "https://chatgpt.com/backend-api".to_string()));
|
||||
let headers = crate::util::build_chatgpt_headers().await;
|
||||
let res = crate::env_detect::list_environments(&base_url, &headers).await;
|
||||
let _ = tx2.send(app::AppEvent::EnvironmentsLoaded(res));
|
||||
});
|
||||
}
|
||||
// Render after opening env modal to show it instantly.
|
||||
render_if_needed(&mut terminal, &mut app, &mut needs_redraw)?;
|
||||
@@ -798,9 +658,7 @@ pub async fn run_main(_cli: Cli, _codex_linux_sandbox_exe: Option<PathBuf>) -> a
|
||||
let id2 = m.task_id.clone();
|
||||
let title2 = m.title.clone();
|
||||
tokio::spawn(async move {
|
||||
unsafe { std::env::set_var("CODEX_APPLY_PREFLIGHT", "1") };
|
||||
let out = codex_cloud_tasks_client::CloudBackend::apply_task(&*backend2, id2.clone()).await;
|
||||
unsafe { std::env::remove_var("CODEX_APPLY_PREFLIGHT") };
|
||||
let out = codex_cloud_tasks_client::CloudBackend::apply_task_preflight(&*backend2, id2.clone()).await;
|
||||
let evt = match out {
|
||||
Ok(outcome) => {
|
||||
let level = match outcome.status {
|
||||
@@ -835,9 +693,7 @@ pub async fn run_main(_cli: Cli, _codex_linux_sandbox_exe: Option<PathBuf>) -> a
|
||||
let id2 = ov.task_id.clone();
|
||||
let title2 = ov.title.clone();
|
||||
tokio::spawn(async move {
|
||||
unsafe { std::env::set_var("CODEX_APPLY_PREFLIGHT", "1") };
|
||||
let out = codex_cloud_tasks_client::CloudBackend::apply_task(&*backend2, id2.clone()).await;
|
||||
unsafe { std::env::remove_var("CODEX_APPLY_PREFLIGHT") };
|
||||
let out = codex_cloud_tasks_client::CloudBackend::apply_task_preflight(&*backend2, id2.clone()).await;
|
||||
let evt = match out {
|
||||
Ok(outcome) => {
|
||||
let level = match outcome.status {
|
||||
@@ -867,29 +723,11 @@ pub async fn run_main(_cli: Cli, _codex_linux_sandbox_exe: Option<PathBuf>) -> a
|
||||
if app.environments.is_empty() {
|
||||
let tx2 = tx.clone();
|
||||
tokio::spawn(async move {
|
||||
// Build headers (UA + ChatGPT token + account id)
|
||||
let mut base_url = std::env::var("CODEX_CLOUD_TASKS_BASE_URL").unwrap_or_else(|_| "https://chatgpt.com/backend-api".to_string());
|
||||
while base_url.ends_with('/') { base_url.pop(); }
|
||||
if (base_url.starts_with("https://chatgpt.com") || base_url.starts_with("https://chat.openai.com")) && !base_url.contains("/backend-api") { base_url = format!("{base_url}/backend-api"); }
|
||||
let ua = codex_core::default_client::get_codex_user_agent(Some("codex_cloud_tasks_tui"));
|
||||
let mut headers = reqwest::header::HeaderMap::new();
|
||||
headers.insert(reqwest::header::USER_AGENT, reqwest::header::HeaderValue::from_str(&ua).unwrap_or(reqwest::header::HeaderValue::from_static("codex-cli")));
|
||||
if let Ok(home) = codex_core::config::find_codex_home() {
|
||||
let am = codex_login::AuthManager::new(
|
||||
home,
|
||||
codex_login::AuthMode::ChatGPT,
|
||||
"codex_cloud_tasks_tui".to_string(),
|
||||
);
|
||||
if let Some(auth) = am.auth() && let Ok(tok) = auth.get_token().await && !tok.is_empty() {
|
||||
let v = format!("Bearer {tok}");
|
||||
if let Ok(hv) = reqwest::header::HeaderValue::from_str(&v) { headers.insert(reqwest::header::AUTHORIZATION, hv); }
|
||||
if let Some(acc) = auth.get_account_id().or_else(|| extract_chatgpt_account_id(&tok))
|
||||
&& let Ok(name) = reqwest::header::HeaderName::from_bytes(b"ChatGPT-Account-Id")
|
||||
&& let Ok(hv) = reqwest::header::HeaderValue::from_str(&acc) {
|
||||
headers.insert(name, hv);
|
||||
}
|
||||
}
|
||||
}
|
||||
let base_url = crate::util::normalize_base_url(
|
||||
&std::env::var("CODEX_CLOUD_TASKS_BASE_URL")
|
||||
.unwrap_or_else(|_| "https://chatgpt.com/backend-api".to_string()),
|
||||
);
|
||||
let headers = crate::util::build_chatgpt_headers().await;
|
||||
let res = crate::env_detect::list_environments(&base_url, &headers).await;
|
||||
let _ = tx2.send(app::AppEvent::EnvironmentsLoaded(res));
|
||||
});
|
||||
@@ -957,28 +795,8 @@ pub async fn run_main(_cli: Cli, _codex_linux_sandbox_exe: Option<PathBuf>) -> a
|
||||
let _ = frame_tx.send(Instant::now() + Duration::from_millis(100));
|
||||
let tx2 = tx.clone();
|
||||
tokio::spawn(async move {
|
||||
// Build headers (UA + ChatGPT token + account id)
|
||||
let mut base_url = std::env::var("CODEX_CLOUD_TASKS_BASE_URL").unwrap_or_else(|_| "https://chatgpt.com/backend-api".to_string());
|
||||
while base_url.ends_with('/') { base_url.pop(); }
|
||||
if (base_url.starts_with("https://chatgpt.com") || base_url.starts_with("https://chat.openai.com")) && !base_url.contains("/backend-api") { base_url = format!("{base_url}/backend-api"); }
|
||||
let ua = codex_core::default_client::get_codex_user_agent(Some("codex_cloud_tasks_tui"));
|
||||
let mut headers = reqwest::header::HeaderMap::new();
|
||||
headers.insert(reqwest::header::USER_AGENT, reqwest::header::HeaderValue::from_str(&ua).unwrap_or(reqwest::header::HeaderValue::from_static("codex-cli")));
|
||||
if let Ok(home) = codex_core::config::find_codex_home() {
|
||||
let am = codex_login::AuthManager::new(
|
||||
home,
|
||||
codex_login::AuthMode::ChatGPT,
|
||||
"codex_cloud_tasks_tui".to_string(),
|
||||
);
|
||||
if let Some(auth) = am.auth()
|
||||
&& let Ok(tok) = auth.get_token().await && !tok.is_empty() {
|
||||
let v = format!("Bearer {tok}");
|
||||
if let Ok(hv) = reqwest::header::HeaderValue::from_str(&v) { headers.insert(reqwest::header::AUTHORIZATION, hv); }
|
||||
if let Some(acc) = auth.get_account_id().or_else(|| extract_chatgpt_account_id(&tok))
|
||||
&& let Ok(name) = reqwest::header::HeaderName::from_bytes(b"ChatGPT-Account-Id")
|
||||
&& let Ok(hv) = reqwest::header::HeaderValue::from_str(&acc) { headers.insert(name, hv); }
|
||||
}
|
||||
}
|
||||
let base_url = crate::util::normalize_base_url(&std::env::var("CODEX_CLOUD_TASKS_BASE_URL").unwrap_or_else(|_| "https://chatgpt.com/backend-api".to_string()));
|
||||
let headers = crate::util::build_chatgpt_headers().await;
|
||||
let res = crate::env_detect::list_environments(&base_url, &headers).await;
|
||||
let _ = tx2.send(app::AppEvent::EnvironmentsLoaded(res));
|
||||
});
|
||||
@@ -1094,33 +912,13 @@ pub async fn run_main(_cli: Cli, _codex_linux_sandbox_exe: Option<PathBuf>) -> a
|
||||
if should_fetch { app.env_loading = true; app.env_error = None; }
|
||||
needs_redraw = true;
|
||||
if should_fetch {
|
||||
let tx2 = tx.clone();
|
||||
tokio::spawn(async move {
|
||||
// Build headers (UA + ChatGPT token + account id)
|
||||
let mut base_url = std::env::var("CODEX_CLOUD_TASKS_BASE_URL").unwrap_or_else(|_| "https://chatgpt.com/backend-api".to_string());
|
||||
while base_url.ends_with('/') { base_url.pop(); }
|
||||
if (base_url.starts_with("https://chatgpt.com") || base_url.starts_with("https://chat.openai.com")) && !base_url.contains("/backend-api") { base_url = format!("{base_url}/backend-api"); }
|
||||
let ua = codex_core::default_client::get_codex_user_agent(Some("codex_cloud_tasks_tui"));
|
||||
let mut headers = reqwest::header::HeaderMap::new();
|
||||
headers.insert(reqwest::header::USER_AGENT, reqwest::header::HeaderValue::from_str(&ua).unwrap_or(reqwest::header::HeaderValue::from_static("codex-cli")));
|
||||
if let Ok(home) = codex_core::config::find_codex_home() {
|
||||
let am = codex_login::AuthManager::new(
|
||||
home,
|
||||
codex_login::AuthMode::ChatGPT,
|
||||
"codex_cloud_tasks_tui".to_string(),
|
||||
);
|
||||
if let Some(auth) = am.auth()
|
||||
&& let Ok(tok) = auth.get_token().await && !tok.is_empty() {
|
||||
let v = format!("Bearer {tok}");
|
||||
if let Ok(hv) = reqwest::header::HeaderValue::from_str(&v) { headers.insert(reqwest::header::AUTHORIZATION, hv); }
|
||||
if let Some(acc) = auth.get_account_id().or_else(|| extract_chatgpt_account_id(&tok))
|
||||
&& let Ok(name) = reqwest::header::HeaderName::from_bytes(b"ChatGPT-Account-Id")
|
||||
&& let Ok(hv) = reqwest::header::HeaderValue::from_str(&acc) { headers.insert(name, hv); }
|
||||
}
|
||||
}
|
||||
let res = crate::env_detect::list_environments(&base_url, &headers).await;
|
||||
let _ = tx2.send(app::AppEvent::EnvironmentsLoaded(res));
|
||||
});
|
||||
let tx2 = tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let base_url = crate::util::normalize_base_url(&std::env::var("CODEX_CLOUD_TASKS_BASE_URL").unwrap_or_else(|_| "https://chatgpt.com/backend-api".to_string()));
|
||||
let headers = crate::util::build_chatgpt_headers().await;
|
||||
let res = crate::env_detect::list_environments(&base_url, &headers).await;
|
||||
let _ = tx2.send(app::AppEvent::EnvironmentsLoaded(res));
|
||||
});
|
||||
}
|
||||
}
|
||||
KeyCode::Char('n') => {
|
||||
@@ -1147,11 +945,20 @@ pub async fn run_main(_cli: Cli, _codex_linux_sandbox_exe: Option<PathBuf>) -> a
|
||||
let title2 = title1.clone();
|
||||
tokio::spawn(async move {
|
||||
match codex_cloud_tasks_client::CloudBackend::get_task_diff(&*backend2, id1.clone()).await {
|
||||
Ok(diff) => {
|
||||
Ok(Some(diff)) => {
|
||||
let _ = tx2.send(app::AppEvent::DetailsDiffLoaded { id: id1, title: title1, diff });
|
||||
}
|
||||
Ok(None) => {
|
||||
match codex_cloud_tasks_client::CloudBackend::get_task_text(&*backend2, id1.clone()).await {
|
||||
Ok(text) => {
|
||||
let _ = tx2.send(app::AppEvent::DetailsMessagesLoaded { id: id1, title: title1, messages: text.messages, prompt: text.prompt });
|
||||
}
|
||||
Err(e2) => {
|
||||
let _ = tx2.send(app::AppEvent::DetailsFailed { id: id1, title: title1, error: format!("{e2}") });
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
// Always log errors while we debug non-success states.
|
||||
append_error_log(format!("get_task_diff failed for {}: {e}", id1.0));
|
||||
match codex_cloud_tasks_client::CloudBackend::get_task_text(&*backend2, id1.clone()).await {
|
||||
Ok(text) => {
|
||||
@@ -1183,7 +990,7 @@ pub async fn run_main(_cli: Cli, _codex_linux_sandbox_exe: Option<PathBuf>) -> a
|
||||
KeyCode::Char('a') => {
|
||||
if let Some(task) = app.tasks.get(app.selected) {
|
||||
match codex_cloud_tasks_client::CloudBackend::get_task_diff(&*backend, task.id.clone()).await {
|
||||
Ok(_) => {
|
||||
Ok(Some(_)) => {
|
||||
app.apply_modal = Some(app::ApplyModalState { task_id: task.id.clone(), title: task.title.clone(), result_message: None, result_level: None, skipped_paths: Vec::new(), conflict_paths: Vec::new() });
|
||||
app.apply_preflight_inflight = true;
|
||||
let _ = frame_tx.send(Instant::now() + Duration::from_millis(100));
|
||||
@@ -1192,9 +999,7 @@ pub async fn run_main(_cli: Cli, _codex_linux_sandbox_exe: Option<PathBuf>) -> a
|
||||
let id2 = task.id.clone();
|
||||
let title2 = task.title.clone();
|
||||
tokio::spawn(async move {
|
||||
unsafe { std::env::set_var("CODEX_APPLY_PREFLIGHT", "1") };
|
||||
let out = codex_cloud_tasks_client::CloudBackend::apply_task(&*backend2, id2.clone()).await;
|
||||
unsafe { std::env::remove_var("CODEX_APPLY_PREFLIGHT") };
|
||||
let out = codex_cloud_tasks_client::CloudBackend::apply_task_preflight(&*backend2, id2.clone()).await;
|
||||
let evt = match out {
|
||||
Ok(outcome) => {
|
||||
let level = match outcome.status {
|
||||
@@ -1209,7 +1014,7 @@ pub async fn run_main(_cli: Cli, _codex_linux_sandbox_exe: Option<PathBuf>) -> a
|
||||
let _ = tx2.send(evt);
|
||||
});
|
||||
}
|
||||
Err(_) => {
|
||||
Ok(None) | Err(_) => {
|
||||
app.status = "No diff available to apply".to_string();
|
||||
}
|
||||
}
|
||||
@@ -1249,22 +1054,7 @@ pub async fn run_main(_cli: Cli, _codex_linux_sandbox_exe: Option<PathBuf>) -> a
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn extract_chatgpt_account_id(token: &str) -> Option<String> {
|
||||
// JWT: header.payload.signature
|
||||
let mut parts = token.split('.');
|
||||
let (_h, payload_b64, _s) = match (parts.next(), parts.next(), parts.next()) {
|
||||
(Some(h), Some(p), Some(s)) if !h.is_empty() && !p.is_empty() && !s.is_empty() => (h, p, s),
|
||||
_ => return None,
|
||||
};
|
||||
let payload_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
|
||||
.decode(payload_b64)
|
||||
.ok()?;
|
||||
let v: serde_json::Value = serde_json::from_slice(&payload_bytes).ok()?;
|
||||
v.get("https://api.openai.com/auth")
|
||||
.and_then(|auth| auth.get("chatgpt_account_id"))
|
||||
.and_then(|id| id.as_str())
|
||||
.map(|s| s.to_string())
|
||||
}
|
||||
// extract_chatgpt_account_id moved to util.rs
|
||||
|
||||
/// Build plain-text conversation lines: a labeled user prompt followed by assistant messages.
|
||||
fn conversation_lines(prompt: Option<String>, messages: &[String]) -> Vec<String> {
|
||||
|
||||
@@ -286,7 +286,11 @@ fn draw_diff_overlay(frame: &mut Frame, area: Rect, app: &mut App) {
|
||||
if app.diff_overlay.is_none() {
|
||||
return;
|
||||
}
|
||||
let ov_can_apply = app.diff_overlay.as_ref().map(|o| o.can_apply).unwrap_or(false);
|
||||
let ov_can_apply = app
|
||||
.diff_overlay
|
||||
.as_ref()
|
||||
.map(|o| o.can_apply)
|
||||
.unwrap_or(false);
|
||||
let is_error = app
|
||||
.diff_overlay
|
||||
.as_ref()
|
||||
@@ -302,7 +306,12 @@ fn draw_diff_overlay(frame: &mut Frame, area: Rect, app: &mut App) {
|
||||
|
||||
// Title block
|
||||
let mut title_spans: Vec<ratatui::text::Span> = if is_error {
|
||||
vec!["Details ".magenta(), "[FAILED]".red().bold(), " ".into(), title.clone().magenta()]
|
||||
vec![
|
||||
"Details ".magenta(),
|
||||
"[FAILED]".red().bold(),
|
||||
" ".into(),
|
||||
title.clone().magenta(),
|
||||
]
|
||||
} else if ov_can_apply {
|
||||
vec!["Diff: ".magenta(), title.clone().magenta()]
|
||||
} else {
|
||||
@@ -317,7 +326,10 @@ fn draw_diff_overlay(frame: &mut Frame, area: Rect, app: &mut App) {
|
||||
title_spans.push(format!("{p}%").dim());
|
||||
}
|
||||
frame.render_widget(Clear, inner);
|
||||
frame.render_widget(overlay_block().title(Line::from(title_spans)).clone(), inner);
|
||||
frame.render_widget(
|
||||
overlay_block().title(Line::from(title_spans)).clone(),
|
||||
inner,
|
||||
);
|
||||
|
||||
// Content area and optional status bar
|
||||
let content_full = overlay_content(inner);
|
||||
@@ -333,9 +345,23 @@ fn draw_diff_overlay(frame: &mut Frame, area: Rect, app: &mut App) {
|
||||
// Status bar label
|
||||
let mut spans: Vec<ratatui::text::Span> = Vec::new();
|
||||
if has_diff && has_text {
|
||||
let prompt_lbl = if matches!(ov.current_view, crate::app::DetailView::Prompt) { "[Prompt]".magenta().bold() } else { "Prompt".dim() };
|
||||
let diff_lbl = if matches!(ov.current_view, crate::app::DetailView::Diff) { "[Diff]".magenta().bold() } else { "Diff".dim() };
|
||||
spans.extend(vec![prompt_lbl, " ".into(), diff_lbl, " ".into(), "(← → to switch)".dim()]);
|
||||
let prompt_lbl = if matches!(ov.current_view, crate::app::DetailView::Prompt) {
|
||||
"[Prompt]".magenta().bold()
|
||||
} else {
|
||||
"Prompt".dim()
|
||||
};
|
||||
let diff_lbl = if matches!(ov.current_view, crate::app::DetailView::Diff) {
|
||||
"[Diff]".magenta().bold()
|
||||
} else {
|
||||
"Diff".dim()
|
||||
};
|
||||
spans.extend(vec![
|
||||
prompt_lbl,
|
||||
" ".into(),
|
||||
diff_lbl,
|
||||
" ".into(),
|
||||
"(← → to switch)".dim(),
|
||||
]);
|
||||
} else if has_text {
|
||||
spans.push("Conversation".magenta().bold());
|
||||
} else {
|
||||
@@ -361,7 +387,10 @@ fn draw_diff_overlay(frame: &mut Frame, area: Rect, app: &mut App) {
|
||||
.unwrap_or(false);
|
||||
let styled_lines: Vec<Line<'static>> = if is_diff_view {
|
||||
let raw = app.diff_overlay.as_ref().map(|o| o.sd.wrapped_lines());
|
||||
raw.unwrap_or(&[]).iter().map(|l| style_diff_line(l)).collect()
|
||||
raw.unwrap_or(&[])
|
||||
.iter()
|
||||
.map(|l| style_diff_line(l))
|
||||
.collect()
|
||||
} else {
|
||||
let mut in_code = false;
|
||||
let raw = app.diff_overlay.as_ref().map(|o| o.sd.wrapped_lines());
|
||||
|
||||
90
codex-rs/cloud-tasks/src/util.rs
Normal file
90
codex-rs/cloud-tasks/src/util.rs
Normal file
@@ -0,0 +1,90 @@
|
||||
use base64::Engine as _;
|
||||
use chrono::Utc;
|
||||
use reqwest::header::HeaderMap;
|
||||
|
||||
pub fn append_error_log(message: impl AsRef<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.as_ref());
|
||||
}
|
||||
}
|
||||
|
||||
/// Normalize the configured base URL to a canonical form used by the backend client.
|
||||
/// - trims trailing '/'
|
||||
/// - appends '/backend-api' for ChatGPT hosts when missing
|
||||
pub fn normalize_base_url(input: &str) -> String {
|
||||
let mut base_url = input.to_string();
|
||||
while base_url.ends_with('/') {
|
||||
base_url.pop();
|
||||
}
|
||||
if (base_url.starts_with("https://chatgpt.com")
|
||||
|| base_url.starts_with("https://chat.openai.com"))
|
||||
&& !base_url.contains("/backend-api")
|
||||
{
|
||||
base_url = format!("{base_url}/backend-api");
|
||||
}
|
||||
base_url
|
||||
}
|
||||
|
||||
/// Extract the ChatGPT account id from a JWT token, when present.
|
||||
pub fn extract_chatgpt_account_id(token: &str) -> Option<String> {
|
||||
let mut parts = token.split('.');
|
||||
let (_h, payload_b64, _s) = match (parts.next(), parts.next(), parts.next()) {
|
||||
(Some(h), Some(p), Some(s)) if !h.is_empty() && !p.is_empty() && !s.is_empty() => (h, p, s),
|
||||
_ => return None,
|
||||
};
|
||||
let payload_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
|
||||
.decode(payload_b64)
|
||||
.ok()?;
|
||||
let v: serde_json::Value = serde_json::from_slice(&payload_bytes).ok()?;
|
||||
v.get("https://api.openai.com/auth")
|
||||
.and_then(|auth| auth.get("chatgpt_account_id"))
|
||||
.and_then(|id| id.as_str())
|
||||
.map(|s| s.to_string())
|
||||
}
|
||||
|
||||
/// Build headers for ChatGPT-backed requests: `User-Agent`, optional `Authorization`,
|
||||
/// and optional `ChatGPT-Account-Id`.
|
||||
pub async fn build_chatgpt_headers() -> HeaderMap {
|
||||
use reqwest::header::AUTHORIZATION;
|
||||
use reqwest::header::HeaderName;
|
||||
use reqwest::header::HeaderValue;
|
||||
use reqwest::header::USER_AGENT;
|
||||
|
||||
let ua = codex_core::default_client::get_codex_user_agent(Some("codex_cloud_tasks_tui"));
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
USER_AGENT,
|
||||
HeaderValue::from_str(&ua).unwrap_or(HeaderValue::from_static("codex-cli")),
|
||||
);
|
||||
if let Ok(home) = codex_core::config::find_codex_home() {
|
||||
let am = codex_login::AuthManager::new(
|
||||
home,
|
||||
codex_login::AuthMode::ChatGPT,
|
||||
"codex_cloud_tasks_tui".to_string(),
|
||||
);
|
||||
if let Some(auth) = am.auth()
|
||||
&& let Ok(tok) = auth.get_token().await
|
||||
&& !tok.is_empty()
|
||||
{
|
||||
let v = format!("Bearer {tok}");
|
||||
if let Ok(hv) = HeaderValue::from_str(&v) {
|
||||
headers.insert(AUTHORIZATION, hv);
|
||||
}
|
||||
if let Some(acc) = auth
|
||||
.get_account_id()
|
||||
.or_else(|| extract_chatgpt_account_id(&tok))
|
||||
&& let Ok(name) = HeaderName::from_bytes(b"ChatGPT-Account-Id")
|
||||
&& let Ok(hv) = HeaderValue::from_str(&acc)
|
||||
{
|
||||
headers.insert(name, hv);
|
||||
}
|
||||
}
|
||||
}
|
||||
headers
|
||||
}
|
||||
17
codex-rs/git-apply/Cargo.toml
Normal file
17
codex-rs/git-apply/Cargo.toml
Normal file
@@ -0,0 +1,17 @@
|
||||
[package]
|
||||
name = "codex-git-apply"
|
||||
version = { workspace = true }
|
||||
edition = "2024"
|
||||
|
||||
[lib]
|
||||
name = "codex_git_apply"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
once_cell = "1"
|
||||
regex = "1"
|
||||
tempfile = "3"
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used)]
|
||||
|
||||
use once_cell::sync::Lazy;
|
||||
use regex::Regex;
|
||||
use std::ffi::OsStr;
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use once_cell::sync::Lazy;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ApplyGitRequest {
|
||||
pub cwd: PathBuf,
|
||||
pub diff: String,
|
||||
pub revert: bool,
|
||||
pub preflight: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -58,11 +60,8 @@ pub fn apply_git_patch(req: &ApplyGitRequest) -> io::Result<ApplyGitResult> {
|
||||
|
||||
args.push(patch_path.to_string_lossy().to_string());
|
||||
|
||||
// Optional preflight: CODEX_APPLY_PREFLIGHT=1 (dry-run only; do not modify WT)
|
||||
if matches!(
|
||||
std::env::var("CODEX_APPLY_PREFLIGHT").ok().as_deref(),
|
||||
Some("1" | "true" | "yes")
|
||||
) {
|
||||
// Optional preflight: dry-run only; do not modify working tree
|
||||
if req.preflight {
|
||||
let mut check_args = vec!["apply".to_string(), "--check".to_string()];
|
||||
if req.revert {
|
||||
check_args.push("-R".to_string());
|
||||
@@ -479,6 +478,7 @@ fn regex_ci(pat: &str) -> Regex {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::Path;
|
||||
use std::sync::Mutex;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
@@ -513,7 +513,6 @@ mod tests {
|
||||
#[test]
|
||||
fn apply_add_success() {
|
||||
let _g = env_lock().lock().unwrap();
|
||||
unsafe { std::env::remove_var("CODEX_APPLY_PREFLIGHT") };
|
||||
let repo = init_repo();
|
||||
let root = repo.path().to_path_buf();
|
||||
|
||||
@@ -522,6 +521,7 @@ mod tests {
|
||||
cwd: root.clone(),
|
||||
diff: diff.to_string(),
|
||||
revert: false,
|
||||
preflight: false,
|
||||
};
|
||||
let r = apply_git_patch(&req).expect("run apply");
|
||||
assert_eq!(r.exit_code, 0, "exit code 0");
|
||||
@@ -532,7 +532,6 @@ mod tests {
|
||||
#[test]
|
||||
fn apply_modify_conflict() {
|
||||
let _g = env_lock().lock().unwrap();
|
||||
unsafe { std::env::remove_var("CODEX_APPLY_PREFLIGHT") };
|
||||
let repo = init_repo();
|
||||
let root = repo.path();
|
||||
// seed file and commit
|
||||
@@ -547,6 +546,7 @@ mod tests {
|
||||
cwd: root.to_path_buf(),
|
||||
diff: diff.to_string(),
|
||||
revert: false,
|
||||
preflight: false,
|
||||
};
|
||||
let r = apply_git_patch(&req).expect("run apply");
|
||||
assert_ne!(r.exit_code, 0, "non-zero exit on conflict");
|
||||
@@ -555,7 +555,6 @@ mod tests {
|
||||
#[test]
|
||||
fn apply_modify_skipped_missing_index() {
|
||||
let _g = env_lock().lock().unwrap();
|
||||
unsafe { std::env::remove_var("CODEX_APPLY_PREFLIGHT") };
|
||||
let repo = init_repo();
|
||||
let root = repo.path();
|
||||
// Try to modify a file that is not in the index
|
||||
@@ -564,6 +563,7 @@ mod tests {
|
||||
cwd: root.to_path_buf(),
|
||||
diff: diff.to_string(),
|
||||
revert: false,
|
||||
preflight: false,
|
||||
};
|
||||
let r = apply_git_patch(&req).expect("run apply");
|
||||
assert_ne!(r.exit_code, 0, "non-zero exit on missing index");
|
||||
@@ -572,7 +572,6 @@ mod tests {
|
||||
#[test]
|
||||
fn apply_then_revert_success() {
|
||||
let _g = env_lock().lock().unwrap();
|
||||
unsafe { std::env::remove_var("CODEX_APPLY_PREFLIGHT") };
|
||||
let repo = init_repo();
|
||||
let root = repo.path();
|
||||
// Seed file and commit original content
|
||||
@@ -586,6 +585,7 @@ mod tests {
|
||||
cwd: root.to_path_buf(),
|
||||
diff: diff.to_string(),
|
||||
revert: false,
|
||||
preflight: false,
|
||||
};
|
||||
let res_apply = apply_git_patch(&apply_req).expect("apply ok");
|
||||
assert_eq!(res_apply.exit_code, 0, "forward apply succeeded");
|
||||
@@ -597,6 +597,7 @@ mod tests {
|
||||
cwd: root.to_path_buf(),
|
||||
diff: diff.to_string(),
|
||||
revert: true,
|
||||
preflight: false,
|
||||
};
|
||||
let res_revert = apply_git_patch(&revert_req).expect("revert ok");
|
||||
assert_eq!(res_revert.exit_code, 0, "revert apply succeeded");
|
||||
@@ -614,14 +615,13 @@ mod tests {
|
||||
diff --git a/ghost.txt b/ghost.txt\n--- a/ghost.txt\n+++ b/ghost.txt\n@@ -1,1 +1,1 @@\n-old\n+new\n";
|
||||
|
||||
// 1) With preflight enabled, nothing should be changed (even though ok.txt could be added)
|
||||
unsafe { std::env::set_var("CODEX_APPLY_PREFLIGHT", "1") };
|
||||
let req1 = ApplyGitRequest {
|
||||
cwd: root.clone(),
|
||||
diff: diff.to_string(),
|
||||
revert: false,
|
||||
preflight: true,
|
||||
};
|
||||
let r1 = apply_git_patch(&req1).expect("preflight apply");
|
||||
unsafe { std::env::remove_var("CODEX_APPLY_PREFLIGHT") };
|
||||
assert_ne!(r1.exit_code, 0, "preflight reports failure");
|
||||
assert!(
|
||||
!root.join("ok.txt").exists(),
|
||||
@@ -637,6 +637,7 @@ diff --git a/ghost.txt b/ghost.txt\n--- a/ghost.txt\n+++ b/ghost.txt\n@@ -1,1 +1
|
||||
cwd: root.clone(),
|
||||
diff: diff.to_string(),
|
||||
revert: false,
|
||||
preflight: false,
|
||||
};
|
||||
let r2 = apply_git_patch(&req2).expect("direct apply");
|
||||
assert_ne!(r2.exit_code, 0, "apply is expected to fail overall");
|
||||
Reference in New Issue
Block a user