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