Skip to content

Commit 5b03813

Browse files
Add cloud tasks (#3197)
Adds a TUI for managing, applying, and creating cloud tasks
1 parent d9dbf48 commit 5b03813

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

49 files changed

+7573
-438
lines changed

codex-rs/Cargo.lock

Lines changed: 505 additions & 388 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

codex-rs/Cargo.toml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
[workspace]
22
members = [
3+
"backend-client",
34
"ansi-escape",
45
"app-server",
56
"apply-patch",
67
"arg0",
8+
"codex-backend-openapi-models",
9+
"cloud-tasks",
10+
"cloud-tasks-client",
711
"cli",
812
"common",
913
"core",
@@ -24,6 +28,7 @@ members = [
2428
"responses-api-proxy",
2529
"otel",
2630
"tui",
31+
"git-apply",
2732
"utils/json-to-toml",
2833
"utils/readiness",
2934
]
@@ -59,6 +64,7 @@ codex-otel = { path = "otel" }
5964
codex-process-hardening = { path = "process-hardening" }
6065
codex-protocol = { path = "protocol" }
6166
codex-protocol-ts = { path = "protocol-ts" }
67+
codex-responses-api-proxy = { path = "responses-api-proxy" }
6268
codex-rmcp-client = { path = "rmcp-client" }
6369
codex-tui = { path = "tui" }
6470
codex-utils-json-to-toml = { path = "utils/json-to-toml" }

codex-rs/backend-client/Cargo.toml

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
[package]
2+
name = "codex-backend-client"
3+
version = "0.0.0"
4+
edition = "2024"
5+
publish = false
6+
7+
[lib]
8+
path = "src/lib.rs"
9+
10+
[dependencies]
11+
anyhow = "1"
12+
serde = { version = "1", features = ["derive"] }
13+
serde_json = "1"
14+
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
15+
codex-backend-openapi-models = { path = "../codex-backend-openapi-models" }
16+
17+
[dev-dependencies]
18+
pretty_assertions = "1"
Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
use crate::types::CodeTaskDetailsResponse;
2+
use crate::types::PaginatedListTaskListItem;
3+
use crate::types::TurnAttemptsSiblingTurnsResponse;
4+
use anyhow::Result;
5+
use reqwest::header::AUTHORIZATION;
6+
use reqwest::header::CONTENT_TYPE;
7+
use reqwest::header::HeaderMap;
8+
use reqwest::header::HeaderName;
9+
use reqwest::header::HeaderValue;
10+
use reqwest::header::USER_AGENT;
11+
use serde::de::DeserializeOwned;
12+
13+
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
14+
pub enum PathStyle {
15+
/// /api/codex/…
16+
CodexApi,
17+
/// /wham/…
18+
ChatGptApi,
19+
}
20+
21+
impl PathStyle {
22+
pub fn from_base_url(base_url: &str) -> Self {
23+
if base_url.contains("/backend-api") {
24+
PathStyle::ChatGptApi
25+
} else {
26+
PathStyle::CodexApi
27+
}
28+
}
29+
}
30+
31+
#[derive(Clone, Debug)]
32+
pub struct Client {
33+
base_url: String,
34+
http: reqwest::Client,
35+
bearer_token: Option<String>,
36+
user_agent: Option<HeaderValue>,
37+
chatgpt_account_id: Option<String>,
38+
path_style: PathStyle,
39+
}
40+
41+
impl Client {
42+
pub fn new(base_url: impl Into<String>) -> Result<Self> {
43+
let mut base_url = base_url.into();
44+
// Normalize common ChatGPT hostnames to include /backend-api so we hit the WHAM paths.
45+
// Also trim trailing slashes for consistent URL building.
46+
while base_url.ends_with('/') {
47+
base_url.pop();
48+
}
49+
if (base_url.starts_with("https://chatgpt.com")
50+
|| base_url.starts_with("https://chat.openai.com"))
51+
&& !base_url.contains("/backend-api")
52+
{
53+
base_url = format!("{base_url}/backend-api");
54+
}
55+
let http = reqwest::Client::builder().build()?;
56+
let path_style = PathStyle::from_base_url(&base_url);
57+
Ok(Self {
58+
base_url,
59+
http,
60+
bearer_token: None,
61+
user_agent: None,
62+
chatgpt_account_id: None,
63+
path_style,
64+
})
65+
}
66+
67+
pub fn with_bearer_token(mut self, token: impl Into<String>) -> Self {
68+
self.bearer_token = Some(token.into());
69+
self
70+
}
71+
72+
pub fn with_user_agent(mut self, ua: impl Into<String>) -> Self {
73+
if let Ok(hv) = HeaderValue::from_str(&ua.into()) {
74+
self.user_agent = Some(hv);
75+
}
76+
self
77+
}
78+
79+
pub fn with_chatgpt_account_id(mut self, account_id: impl Into<String>) -> Self {
80+
self.chatgpt_account_id = Some(account_id.into());
81+
self
82+
}
83+
84+
pub fn with_path_style(mut self, style: PathStyle) -> Self {
85+
self.path_style = style;
86+
self
87+
}
88+
89+
fn headers(&self) -> HeaderMap {
90+
let mut h = HeaderMap::new();
91+
if let Some(ua) = &self.user_agent {
92+
h.insert(USER_AGENT, ua.clone());
93+
} else {
94+
h.insert(USER_AGENT, HeaderValue::from_static("codex-cli"));
95+
}
96+
if let Some(token) = &self.bearer_token {
97+
let value = format!("Bearer {token}");
98+
if let Ok(hv) = HeaderValue::from_str(&value) {
99+
h.insert(AUTHORIZATION, hv);
100+
}
101+
}
102+
if let Some(acc) = &self.chatgpt_account_id
103+
&& let Ok(name) = HeaderName::from_bytes(b"ChatGPT-Account-Id")
104+
&& let Ok(hv) = HeaderValue::from_str(acc)
105+
{
106+
h.insert(name, hv);
107+
}
108+
h
109+
}
110+
111+
async fn exec_request(
112+
&self,
113+
req: reqwest::RequestBuilder,
114+
method: &str,
115+
url: &str,
116+
) -> Result<(String, String)> {
117+
let res = req.send().await?;
118+
let status = res.status();
119+
let ct = res
120+
.headers()
121+
.get(CONTENT_TYPE)
122+
.and_then(|v| v.to_str().ok())
123+
.unwrap_or("")
124+
.to_string();
125+
let body = res.text().await.unwrap_or_default();
126+
if !status.is_success() {
127+
anyhow::bail!("{method} {url} failed: {status}; content-type={ct}; body={body}");
128+
}
129+
Ok((body, ct))
130+
}
131+
132+
fn decode_json<T: DeserializeOwned>(&self, url: &str, ct: &str, body: &str) -> Result<T> {
133+
match serde_json::from_str::<T>(body) {
134+
Ok(v) => Ok(v),
135+
Err(e) => {
136+
anyhow::bail!("Decode error for {url}: {e}; content-type={ct}; body={body}");
137+
}
138+
}
139+
}
140+
141+
pub async fn list_tasks(
142+
&self,
143+
limit: Option<i32>,
144+
task_filter: Option<&str>,
145+
environment_id: Option<&str>,
146+
) -> Result<PaginatedListTaskListItem> {
147+
let url = match self.path_style {
148+
PathStyle::CodexApi => format!("{}/api/codex/tasks/list", self.base_url),
149+
PathStyle::ChatGptApi => format!("{}/wham/tasks/list", self.base_url),
150+
};
151+
let req = self.http.get(&url).headers(self.headers());
152+
let req = if let Some(lim) = limit {
153+
req.query(&[("limit", lim)])
154+
} else {
155+
req
156+
};
157+
let req = if let Some(tf) = task_filter {
158+
req.query(&[("task_filter", tf)])
159+
} else {
160+
req
161+
};
162+
let req = if let Some(id) = environment_id {
163+
req.query(&[("environment_id", id)])
164+
} else {
165+
req
166+
};
167+
let (body, ct) = self.exec_request(req, "GET", &url).await?;
168+
self.decode_json::<PaginatedListTaskListItem>(&url, &ct, &body)
169+
}
170+
171+
pub async fn get_task_details(&self, task_id: &str) -> Result<CodeTaskDetailsResponse> {
172+
let (parsed, _body, _ct) = self.get_task_details_with_body(task_id).await?;
173+
Ok(parsed)
174+
}
175+
176+
pub async fn get_task_details_with_body(
177+
&self,
178+
task_id: &str,
179+
) -> Result<(CodeTaskDetailsResponse, String, String)> {
180+
let url = match self.path_style {
181+
PathStyle::CodexApi => format!("{}/api/codex/tasks/{}", self.base_url, task_id),
182+
PathStyle::ChatGptApi => format!("{}/wham/tasks/{}", self.base_url, task_id),
183+
};
184+
let req = self.http.get(&url).headers(self.headers());
185+
let (body, ct) = self.exec_request(req, "GET", &url).await?;
186+
let parsed: CodeTaskDetailsResponse = self.decode_json(&url, &ct, &body)?;
187+
Ok((parsed, body, ct))
188+
}
189+
190+
pub async fn list_sibling_turns(
191+
&self,
192+
task_id: &str,
193+
turn_id: &str,
194+
) -> Result<TurnAttemptsSiblingTurnsResponse> {
195+
let url = match self.path_style {
196+
PathStyle::CodexApi => format!(
197+
"{}/api/codex/tasks/{}/turns/{}/sibling_turns",
198+
self.base_url, task_id, turn_id
199+
),
200+
PathStyle::ChatGptApi => format!(
201+
"{}/wham/tasks/{}/turns/{}/sibling_turns",
202+
self.base_url, task_id, turn_id
203+
),
204+
};
205+
let req = self.http.get(&url).headers(self.headers());
206+
let (body, ct) = self.exec_request(req, "GET", &url).await?;
207+
self.decode_json::<TurnAttemptsSiblingTurnsResponse>(&url, &ct, &body)
208+
}
209+
210+
/// Create a new task (user turn) by POSTing to the appropriate backend path
211+
/// based on `path_style`. Returns the created task id.
212+
pub async fn create_task(&self, request_body: serde_json::Value) -> Result<String> {
213+
let url = match self.path_style {
214+
PathStyle::CodexApi => format!("{}/api/codex/tasks", self.base_url),
215+
PathStyle::ChatGptApi => format!("{}/wham/tasks", self.base_url),
216+
};
217+
let req = self
218+
.http
219+
.post(&url)
220+
.headers(self.headers())
221+
.header(CONTENT_TYPE, HeaderValue::from_static("application/json"))
222+
.json(&request_body);
223+
let (body, ct) = self.exec_request(req, "POST", &url).await?;
224+
// Extract id from JSON: prefer `task.id`; fallback to top-level `id` when present.
225+
match serde_json::from_str::<serde_json::Value>(&body) {
226+
Ok(v) => {
227+
if let Some(id) = v
228+
.get("task")
229+
.and_then(|t| t.get("id"))
230+
.and_then(|s| s.as_str())
231+
{
232+
Ok(id.to_string())
233+
} else if let Some(id) = v.get("id").and_then(|s| s.as_str()) {
234+
Ok(id.to_string())
235+
} else {
236+
anyhow::bail!(
237+
"POST {url} succeeded but no task id found; content-type={ct}; body={body}"
238+
);
239+
}
240+
}
241+
Err(e) => anyhow::bail!("Decode error for {url}: {e}; content-type={ct}; body={body}"),
242+
}
243+
}
244+
}

codex-rs/backend-client/src/lib.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
mod client;
2+
pub mod types;
3+
4+
pub use client::Client;
5+
pub use types::CodeTaskDetailsResponse;
6+
pub use types::CodeTaskDetailsResponseExt;
7+
pub use types::PaginatedListTaskListItem;
8+
pub use types::TaskListItem;
9+
pub use types::TurnAttemptsSiblingTurnsResponse;

0 commit comments

Comments
 (0)