Skip to main content

onlyne_client/backend/orca/
session.rs

1//! The session layer: the backend's own type and its [`SessionBackend`]
2//! implementation.
3
4use super::cli::{DEAD_STATUS, is_gone, spawn_command};
5use super::policy::{TabMemo, absolute, host_worktree_env};
6use super::resource::{TabKeys, ref_str};
7use crate::backend::*;
8use parking_lot::Mutex;
9use std::collections::BTreeMap;
10use std::sync::Arc;
11
12pub struct OrcaBackend {
13    pub(super) runner: Arc<dyn Runner>,
14    pub(super) command: String,
15    pub(super) policy: WorktreePolicy,
16    /// `ORCA_WORKTREE_ID` as the daemon inherited it: the worktree the
17    /// supervisor's own tab lives in.
18    pub(super) host_worktree: Option<String>,
19    /// Spawn-time facts keyed by `pane_key`.
20    pub(super) tabs: Mutex<BTreeMap<String, TabMemo>>,
21}
22
23impl OrcaBackend {
24    /// Orca backend with the client default policy: tabs land flat in the
25    /// worktree the supervisor's tab lives in.
26    pub fn new(runner: Arc<dyn Runner>) -> Self {
27        Self::with_policy(runner, WorktreePolicy::Host)
28    }
29
30    /// Orca backend with an explicit worktree policy, reading the host
31    /// worktree from the process environment.
32    pub fn with_policy(runner: Arc<dyn Runner>, policy: WorktreePolicy) -> Self {
33        Self::with_host_worktree(runner, policy, host_worktree_env())
34    }
35
36    /// Orca backend that addresses `host_worktree` instead of the environment's
37    /// `ORCA_WORKTREE_ID`. Tests inject it; production reads the inherited
38    /// value through [`OrcaBackend::with_policy`].
39    pub fn with_host_worktree(
40        runner: Arc<dyn Runner>,
41        policy: WorktreePolicy,
42        host_worktree: Option<String>,
43    ) -> Self {
44        Self {
45            runner,
46            command: std::env::var("ORCA_CLI_COMMAND").unwrap_or_else(|_| "orca".into()),
47            policy,
48            host_worktree,
49            tabs: Mutex::new(BTreeMap::new()),
50        }
51    }
52}
53
54impl SessionBackend for OrcaBackend {
55    fn name(&self) -> &'static str {
56        "orca"
57    }
58    fn capabilities(&self) -> Capabilities {
59        Capabilities {
60            spawn: true,
61            attach: true,
62            probe: true,
63            close: true,
64            focus: true,
65            rename: true,
66        }
67    }
68    fn available(&self) -> Result<bool> {
69        Ok(self
70            .json(vec!["terminal".into(), "list".into(), "--json".into()])
71            .is_ok())
72    }
73    fn spawn(&self, spec: SpawnSpec) -> Result<SessionRef> {
74        let title = spec
75            .rename
76            .clone()
77            .unwrap_or_else(|| format!("onlyne:{}", spec.task_id));
78        let selector = self.selector_for();
79        let shell = spawn_command(&spec)?;
80        let mut args = vec!["terminal".into(), "create".into()];
81        if let Some(selector) = &selector {
82            args.push("--worktree".into());
83            args.push(selector.clone());
84        }
85        args.push("--title".into());
86        args.push(title.clone());
87        args.push("--command".into());
88        args.push(shell);
89        if spec.focus.unwrap_or(false) {
90            args.push("--focus".into());
91        }
92        args.push("--json".into());
93        let value = self.json(args)?;
94        let keys = TabKeys::read(&value);
95        if keys.handle.is_none() {
96            // `create` made a tab but named no handle, so the session cannot be
97            // addressed: left alone it would leak a tab that no session ref and
98            // no tab map line records. Roll it back before giving up.
99            return Err(self.roll_back_create(&keys, &title, &value));
100        }
101        let mut backend_ref = keys.to_ref();
102        if let Some(selector) = &selector {
103            backend_ref["selector"] = Value::String(selector.clone());
104        }
105        let session = SessionRef {
106            task_id: spec.task_id.clone(),
107            backend: self.name().into(),
108            backend_ref,
109            generation: 1,
110        };
111        if let Some(pane_key) = keys.pane_key.clone() {
112            self.tabs.lock().insert(
113                pane_key,
114                TabMemo {
115                    root: absolute(&spec.cwd),
116                    role: spec.env.get("ONLYNE_ROLE").cloned().unwrap_or_default(),
117                    session_id: spec
118                        .env
119                        .get("ONLYNE_SESSION_ID")
120                        .cloned()
121                        .unwrap_or_default(),
122                    title,
123                },
124            );
125        }
126        self.note(&session, "spawned");
127        Ok(session)
128    }
129    fn attach(&self, session: &SessionRef) -> Result<SessionRef> {
130        self.current(session).map(|(refreshed, _)| refreshed)
131    }
132    fn probe(&self, session: &SessionRef) -> Result<ResourceProbe> {
133        let (session, value) = match self.current(session) {
134            Ok(found) => found,
135            // A stale handle whose pane_key is in no listing names a resource
136            // that is gone, which is a verdict; anything else is a probe that
137            // could not answer.
138            Err(error) if is_gone(&error) => {
139                return Ok(ResourceProbe {
140                    alive: false,
141                    attached: false,
142                    detail: Some(serde_json::json!({"error": error.to_string()})),
143                });
144            }
145            Err(error) => return Err(error),
146        };
147        let row = value.pointer("/terminal").unwrap_or(&value);
148        let status = row.get("status").and_then(Value::as_str);
149        let exit_cause = row.pointer("/exitCause/kind").and_then(Value::as_str);
150        let connected = row.get("connected").and_then(Value::as_bool);
151        let writable = row.get("writable").and_then(Value::as_bool);
152        // Liveness: an explicit exit cause or a closed status ends the pane,
153        // because `connected`/`writable` keep reading true for a tab the
154        // operator closed. Without either, the pane counts as alive only while
155        // its PTY is connected and writable; a payload with no status at all
156        // is the shape some builds answer for a healthy tab.
157        let ended = exit_cause.is_some() || status.is_some_and(|s| DEAD_STATUS.contains(&s));
158        let alive = !ended
159            && match status {
160                Some(_) => true,
161                None => connected.unwrap_or(false) && writable.unwrap_or(false),
162            };
163        Ok(ResourceProbe {
164            alive,
165            attached: connected.unwrap_or(alive),
166            detail: Some(serde_json::json!({
167                "handle": ref_str(&session, "handle"),
168                "pane_key": ref_str(&session, "pane_key"),
169                "status": status,
170                "exit_cause": exit_cause,
171                "last_output_at": row.get("lastOutputAt"),
172            })),
173        })
174    }
175    fn close(&self, session: &SessionRef, reason: CloseReason, force: bool) -> Result<()> {
176        // Orca has one close path, so the reason and the force flag are
177        // recorded for the log and the tab map instead of mapped onto flags.
178        tracing::debug!(task = %session.task_id, ?reason, force, "closing orca terminal");
179        let current = match self.current(session) {
180            Ok((current, _)) => current,
181            // Nothing addressable is left: closing again is a no-op, and the
182            // tombstone still records the end of the mapping.
183            Err(error) if is_gone(&error) => {
184                self.note(session, "closed");
185                return Ok(());
186            }
187            Err(error) => return Err(error),
188        };
189        self.close_handle(&Self::ref_handle(&current)?)?;
190        self.note(&current, "closed");
191        if let Some(pane_key) = ref_str(&current, "pane_key") {
192            self.tabs.lock().remove(&pane_key);
193        }
194        Ok(())
195    }
196    fn rename(&self, session: &SessionRef, title: &str) -> Result<()> {
197        self.json(vec![
198            "terminal".into(),
199            "rename".into(),
200            "--terminal".into(),
201            Self::ref_handle(session)?,
202            "--title".into(),
203            title.into(),
204            "--json".into(),
205        ])
206        .map(|_| ())
207    }
208    fn focus(&self, session: &SessionRef) -> Result<()> {
209        self.json(vec![
210            "terminal".into(),
211            "switch".into(),
212            "--terminal".into(),
213            Self::ref_handle(session)?,
214            "--json".into(),
215        ])
216        .map(|_| ())
217    }
218}