Skip to main content

ai_agent/bridge/
create_session.rs

1//! Bridge session creation and management.
2//!
3//! Translated from openclaudecode/src/bridge/createSession.ts
4//!
5//! Functions for creating, fetching, archiving, and updating bridge sessions.
6
7use crate::constants::env::ai;
8use crate::utils::http::get_user_agent;
9use reqwest::header::{AUTHORIZATION, CONTENT_TYPE, HeaderMap, HeaderName, HeaderValue};
10use serde::{Deserialize, Serialize};
11
12/// Git source for session context
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct GitSource {
15    #[serde(rename = "type")]
16    pub source_type: String,
17    pub url: String,
18    pub revision: Option<String>,
19}
20
21/// Git outcome for session context
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct GitOutcome {
24    #[serde(rename = "type")]
25    pub outcome_type: String,
26    #[serde(rename = "git_info")]
27    pub git_info: GitInfo,
28}
29
30/// Git info for GitHub repositories
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct GitInfo {
33    #[serde(rename = "type")]
34    pub info_type: String,
35    pub repo: String,
36    pub branches: Vec<String>,
37}
38
39/// Session event wrapper for POST /v1/sessions endpoint
40#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct SessionEvent {
42    #[serde(rename = "type")]
43    pub event_type: String,
44    pub data: serde_json::Value,
45}
46
47/// Session context for session creation
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct SessionContext {
50    pub sources: Vec<GitSource>,
51    pub outcomes: Vec<GitOutcome>,
52    pub model: Option<String>,
53}
54
55/// Create a session on a bridge environment via POST /v1/sessions.
56///
57/// Used by both `claude remote-control` (empty session so the user has somewhere to
58/// type immediately) and `/remote-control` (session pre-populated with conversation
59/// history).
60///
61/// Returns the session ID on success, or None if creation fails (non-fatal).
62pub async fn create_bridge_session(
63    environment_id: &str,
64    title: Option<&str>,
65    events: Vec<SessionEvent>,
66    git_repo_url: Option<&str>,
67    branch: Option<&str>,
68    base_url: Option<&str>,
69    get_access_token: Option<&dyn Fn() -> Option<String>>,
70    permission_mode: Option<&str>,
71) -> Option<String> {
72    // Get access token
73    let access_token = get_access_token
74        .and_then(|f| f())
75        .or_else(|| crate::bridge::get_bridge_access_token());
76
77    let access_token = match access_token {
78        Some(t) => t,
79        None => {
80            log_for_debugging("[bridge] No access token for session creation");
81            return None;
82        }
83    };
84
85    // Get organization UUID
86    let org_uuid = get_organization_uuid();
87    let org_uuid = match org_uuid {
88        Some(uuid) => uuid,
89        None => {
90            log_for_debugging("[bridge] No org UUID for session creation");
91            return None;
92        }
93    };
94
95    // Build git source and outcome context
96    let (git_source, git_outcome) = if let Some(repo_url) = git_repo_url {
97        build_git_context(repo_url, branch)
98    } else {
99        (None, None)
100    };
101
102    // Build request body
103    let mut request_body = serde_json::json!({
104        "events": events,
105        "session_context": {
106            "sources": git_source.map(|s| vec![s]).unwrap_or_default(),
107            "outcomes": git_outcome.map(|o| vec![o]).unwrap_or_default(),
108            "model": get_main_loop_model(),
109        },
110        "environment_id": environment_id,
111        "source": "remote-control",
112    });
113
114    if let Some(t) = title {
115        request_body["title"] = serde_json::json!(t);
116    }
117
118    if let Some(mode) = permission_mode {
119        request_body["permission_mode"] = serde_json::json!(mode);
120    }
121
122    let headers = build_oauth_headers(&access_token, &org_uuid);
123
124    let url = format!("{}/v1/sessions", base_url.unwrap_or(&get_oauth_config()));
125
126    let client = reqwest::Client::new();
127    let response = client
128        .post(&url)
129        .headers(headers)
130        .json(&request_body)
131        .send()
132        .await
133        .ok()?;
134
135    let status = response.status();
136    if status != reqwest::StatusCode::OK && status != reqwest::StatusCode::CREATED {
137        let status_code = status.as_u16();
138        let body = response.text().await.unwrap_or_default();
139        let detail = extract_error_detail_from_text(&body);
140        log_for_debugging(&format!(
141            "[bridge] Session creation failed with status {}{}",
142            status_code,
143            detail.map(|d| format!(": {}", d)).unwrap_or_default()
144        ));
145        return None;
146    }
147
148    let session_data: serde_json::Value = response.json().await.ok()?;
149
150    let session_id = session_data.get("id")?.as_str()?.to_string();
151    Some(session_id)
152}
153
154/// Fetch a bridge session via GET /v1/sessions/{id}.
155///
156/// Returns the session's environment_id (for `--session-id` resume) and title.
157/// Uses the same org-scoped headers as create/archive — the environments-level
158/// client in bridgeApi.ts uses a different beta header and no org UUID, which
159/// makes the Sessions API return 404.
160pub async fn get_bridge_session(
161    session_id: &str,
162    base_url: Option<&str>,
163    get_access_token: Option<&dyn Fn() -> Option<String>>,
164) -> Option<BridgeSessionInfo> {
165    // Get access token
166    let access_token = get_access_token
167        .and_then(|f| f())
168        .or_else(|| crate::bridge::get_bridge_access_token());
169
170    let access_token = match access_token {
171        Some(t) => t,
172        None => {
173            log_for_debugging("[bridge] No access token for session fetch");
174            return None;
175        }
176    };
177
178    // Get organization UUID
179    let org_uuid = get_organization_uuid();
180    let org_uuid = match org_uuid {
181        Some(uuid) => uuid,
182        None => {
183            log_for_debugging("[bridge] No org UUID for session fetch");
184            return None;
185        }
186    };
187
188    let headers = build_oauth_headers(&access_token, &org_uuid);
189
190    let url = format!(
191        "{}/v1/sessions/{}",
192        base_url.unwrap_or(&get_oauth_config()),
193        session_id
194    );
195
196    log_for_debugging(&format!("[bridge] Fetching session {}", session_id));
197
198    let client = reqwest::Client::new();
199    let response = client
200        .get(&url)
201        .headers(headers)
202        .timeout(std::time::Duration::from_secs(10))
203        .send()
204        .await
205        .ok()?;
206
207    let status = response.status();
208    if status != reqwest::StatusCode::OK {
209        let status_code = status.as_u16();
210        let body = response.text().await.unwrap_or_default();
211        let detail = extract_error_detail_from_text(&body);
212        log_for_debugging(&format!(
213            "[bridge] Session fetch failed with status {}{}",
214            status_code,
215            detail.map(|d| format!(": {}", d)).unwrap_or_default()
216        ));
217        return None;
218    }
219
220    let data: serde_json::Value = response.json().await.ok()?;
221    Some(BridgeSessionInfo {
222        environment_id: data
223            .get("environment_id")
224            .and_then(|v| v.as_str())
225            .map(String::from),
226        title: data.get("title").and_then(|v| v.as_str()).map(String::from),
227    })
228}
229
230/// Bridge session info
231#[derive(Debug, Clone)]
232pub struct BridgeSessionInfo {
233    pub environment_id: Option<String>,
234    pub title: Option<String>,
235}
236
237/// Archive a bridge session via POST /v1/sessions/{id}/archive.
238///
239/// The CCR server never auto-archives sessions — archival is always an
240/// explicit client action. Both `claude remote-control` (standalone bridge) and the
241/// always-on `/remote-control` REPL bridge call this during shutdown to archive any
242/// sessions that are still alive.
243///
244/// The archive endpoint accepts sessions in any status (running, idle,
245/// requires_action, pending) and returns 409 if already archived, making
246/// it safe to call even if the server-side runner already archived the
247/// session.
248///
249/// Callers must handle errors — this function has no try/catch; 5xx,
250/// timeouts, and network errors throw. Archival is best-effort during
251/// cleanup; call sites wrap with .catch().
252pub async fn archive_bridge_session(
253    session_id: &str,
254    base_url: Option<&str>,
255    get_access_token: Option<&dyn Fn() -> Option<String>>,
256    timeout_ms: Option<u64>,
257) -> Result<(), String> {
258    // Get access token
259    let access_token = get_access_token
260        .and_then(|f| f())
261        .or_else(|| crate::bridge::get_bridge_access_token());
262
263    let access_token = match access_token {
264        Some(t) => t,
265        None => {
266            log_for_debugging("[bridge] No access token for session archive");
267            return Err("No access token".to_string());
268        }
269    };
270
271    // Get organization UUID
272    let org_uuid = get_organization_uuid();
273    let org_uuid = match org_uuid {
274        Some(uuid) => uuid,
275        None => {
276            log_for_debugging("[bridge] No org UUID for session archive");
277            return Err("No org UUID".to_string());
278        }
279    };
280
281    let headers = build_oauth_headers(&access_token, &org_uuid);
282
283    let url = format!(
284        "{}/v1/sessions/{}/archive",
285        base_url.unwrap_or(&get_oauth_config()),
286        session_id
287    );
288
289    log_for_debugging(&format!("[bridge] Archiving session {}", session_id));
290
291    let client = reqwest::Client::new();
292    let response = client
293        .post(&url)
294        .headers(headers)
295        .timeout(std::time::Duration::from_millis(
296            timeout_ms.unwrap_or(10_000),
297        ))
298        .json(&serde_json::json!({}))
299        .send()
300        .await
301        .map_err(|e| format!("Request failed: {}", e))?;
302
303    if response.status() == reqwest::StatusCode::OK {
304        log_for_debugging(&format!(
305            "[bridge] Session {} archived successfully",
306            session_id
307        ));
308        Ok(())
309    } else {
310        let status_code = response.status().as_u16();
311        let body = response.text().await.unwrap_or_default();
312        let detail = extract_error_detail_from_text(&body);
313        Err(format!(
314            "Session archive failed with status {}{}",
315            status_code,
316            detail.map(|d| format!(": {}", d)).unwrap_or_default()
317        ))
318    }
319}
320
321/// Update the title of a bridge session via PATCH /v1/sessions/{id}.
322///
323/// Called when the user renames a session via /rename while a bridge
324/// connection is active, so the title stays in sync on claude.ai/code.
325///
326/// Errors are swallowed — title sync is best-effort.
327pub async fn update_bridge_session_title(
328    session_id: &str,
329    title: &str,
330    base_url: Option<&str>,
331    get_access_token: Option<&dyn Fn() -> Option<String>>,
332) {
333    // Get access token
334    let access_token = get_access_token
335        .and_then(|f| f())
336        .or_else(|| crate::bridge::get_bridge_access_token());
337
338    let access_token = match access_token {
339        Some(t) => t,
340        None => {
341            log_for_debugging("[bridge] No access token for session title update");
342            return;
343        }
344    };
345
346    // Get organization UUID
347    let org_uuid = get_organization_uuid();
348    let org_uuid = match org_uuid {
349        Some(uuid) => uuid,
350        None => {
351            log_for_debugging("[bridge] No org UUID for session title update");
352            return;
353        }
354    };
355
356    let headers = build_oauth_headers(&access_token, &org_uuid);
357
358    // Compat gateway only accepts session_* (compat/convert.go:27). v2 callers
359    // pass raw cse_*; retag here so all callers can pass whatever they hold.
360    // Idempotent for v1's session_* and bridgeMain's pre-converted compatSessionId.
361    let compat_id = crate::bridge::to_compat_session_id(session_id);
362
363    let url = format!(
364        "{}/v1/sessions/{}",
365        base_url.unwrap_or(&get_oauth_config()),
366        compat_id
367    );
368
369    log_for_debugging(&format!(
370        "[bridge] Updating session title: {} → {}",
371        compat_id, title
372    ));
373
374    let client = reqwest::Client::new();
375    match client
376        .patch(&url)
377        .headers(headers)
378        .timeout(std::time::Duration::from_secs(10))
379        .json(&serde_json::json!({ "title": title }))
380        .send()
381        .await
382    {
383        Ok(response) => {
384            if response.status() == reqwest::StatusCode::OK {
385                log_for_debugging("[bridge] Session title updated successfully");
386            } else {
387                let status_code = response.status().as_u16();
388                let body = response.text().await.unwrap_or_default();
389                let detail = extract_error_detail_from_text(&body);
390                log_for_debugging(&format!(
391                    "[bridge] Session title update failed with status {}{}",
392                    status_code,
393                    detail.map(|d| format!(": {}", d)).unwrap_or_default()
394                ));
395            }
396        }
397        Err(e) => {
398            log_for_debugging(&format!(
399                "[bridge] Session title update request failed: {}",
400                e
401            ));
402        }
403    }
404}
405
406// =============================================================================
407// HELPER FUNCTIONS
408// =============================================================================
409
410/// Build git source and outcome context from repo URL
411fn build_git_context(
412    git_repo_url: &str,
413    branch: Option<&str>,
414) -> (Option<GitSource>, Option<GitOutcome>) {
415    // Try to parse git remote URL
416    let parsed = parse_git_remote(git_repo_url);
417
418    if let Some((host, owner, name)) = parsed {
419        let revision = branch.map(String::from).or_else(get_default_branch);
420        let source = GitSource {
421            source_type: "git_repository".to_string(),
422            url: format!("https://{}/{}/{}", host, owner, name),
423            revision,
424        };
425        let outcome = GitOutcome {
426            outcome_type: "git_repository".to_string(),
427            git_info: GitInfo {
428                info_type: "github".to_string(),
429                repo: format!("{}/{}", owner, name),
430                branches: vec![format!("claude/{}", branch.unwrap_or("task"))],
431            },
432        };
433        (Some(source), Some(outcome))
434    } else {
435        // Fallback: try parseGitHubRepository for owner/repo format
436        if let Some((owner, name)) = parse_github_repository(git_repo_url) {
437            let revision = branch.map(String::from).or_else(get_default_branch);
438            let source = GitSource {
439                source_type: "git_repository".to_string(),
440                url: format!("https://github.com/{}/{}", owner, name),
441                revision,
442            };
443            let outcome = GitOutcome {
444                outcome_type: "git_repository".to_string(),
445                git_info: GitInfo {
446                    info_type: "github".to_string(),
447                    repo: format!("{}/{}", owner, name),
448                    branches: vec![format!("claude/{}", branch.unwrap_or("task"))],
449                },
450            };
451            (Some(source), Some(outcome))
452        } else {
453            (None, None)
454        }
455    }
456}
457
458/// Parse git remote URL to extract host, owner, and name
459fn parse_git_remote(url: &str) -> Option<(String, String, String)> {
460    // Simple HTTPS URL parsing
461    // Format: https://host/owner/name or https://host/owner/name.git
462    let url = url.trim_end_matches(".git");
463
464    let parts: Vec<&str> = url.split('/').collect();
465    if parts.len() >= 3 {
466        // Could be host/owner/name or protocol/host/owner/name
467        let start = if parts[0] == "https:" || parts[0] == "http:" {
468            2
469        } else {
470            0
471        };
472        if parts.len() >= start + 3 {
473            let host = if start == 2 {
474                parts[1].to_string()
475            } else {
476                "github.com".to_string()
477            };
478            let owner = parts[start].to_string();
479            let name = parts[start + 1].to_string();
480            return Some((host, owner, name));
481        }
482    }
483    None
484}
485
486/// Parse GitHub repository in owner/repo format
487fn parse_github_repository(s: &str) -> Option<(String, String)> {
488    let parts: Vec<&str> = s.split('/').collect();
489    if parts.len() >= 2 {
490        Some((parts[0].to_string(), parts[1].to_string()))
491    } else {
492        None
493    }
494}
495
496/// Get default branch name (simplified)
497fn get_default_branch() -> Option<String> {
498    use std::process::Command;
499    let output = Command::new("git")
500        .args(&["rev-parse", "--abbrev-ref", "HEAD"])
501        .output()
502        .ok()?;
503
504    if output.status.success() {
505        Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
506    } else {
507        None
508    }
509}
510
511/// Get main loop model
512fn get_main_loop_model() -> Option<String> {
513    // Simplified: would need to get from config
514    None
515}
516
517/// Get organization UUID
518fn get_organization_uuid() -> Option<String> {
519    // Simplified: would need to get from OAuth client
520    None
521}
522
523/// OAuth config
524struct OAuthConfig {
525    BASE_API_URL: String,
526}
527
528fn get_oauth_config() -> String {
529    std::env::var(ai::API_BASE_URL).unwrap_or_else(|_| "https://api.claude.ai".to_string())
530}
531
532/// Build OAuth headers
533fn build_oauth_headers(access_token: &str, org_uuid: &str) -> HeaderMap {
534    let mut headers = HeaderMap::new();
535    if let Ok(val) = HeaderValue::from_str(&format!("Bearer {}", access_token)) {
536        headers.insert(AUTHORIZATION, val);
537    }
538    headers.insert(
539        HeaderName::from_static("anthropic-beta"),
540        HeaderValue::from_static("ccr-byoc-2025-07-29"),
541    );
542    if let Ok(val) = HeaderValue::from_str(org_uuid) {
543        headers.insert(HeaderName::from_static("x-organization-uuid"), val);
544    }
545    // Add Content-Type for JSON
546    headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
547    headers.insert("User-Agent", get_user_agent().parse().unwrap());
548    headers
549}
550
551/// Extract error detail from response body text
552fn extract_error_detail_from_text(body: &str) -> Option<String> {
553    let data: serde_json::Value = serde_json::from_str(body).ok()?;
554    data.get("message")
555        .and_then(|m| m.as_str())
556        .map(|s| s.to_string())
557}
558
559/// Simple logging helper
560#[allow(unused_variables)]
561fn log_for_debugging(msg: &str) {
562    eprintln!("{}", msg);
563}