Skip to main content

onlyne_client/backend/orca/
policy.rs

1//! Host-specific policy that stands alone: which worktree a tab lands in, and
2//! the tab map the plugin-facing cache records.
3
4use super::resource::{TabKeys, ref_str};
5use super::session::OrcaBackend;
6use crate::backend::*;
7use chrono::{SecondsFormat, Utc};
8use serde::Serialize;
9use std::io::Write;
10use std::path::{Path, PathBuf};
11
12/// Where `terminal create` puts the new tab.
13///
14/// Session identity belongs to the adapter protocol: the pi plugin owns the
15/// task, and Orca is only the supervisor's management port. So a session tab
16/// lands in the worktree the supervisor's own tab lives in, flat beside every
17/// other tab the supervisor has, and the role workspace never appears in Orca.
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub enum WorktreePolicy {
20    /// Address the worktree the supervisor's tab exported as
21    /// `ORCA_WORKTREE_ID`. A daemon started outside an Orca tab has no such
22    /// value and behaves like [`WorktreePolicy::Inherit`]. The client default.
23    Host,
24    /// Pass no selector: the tab lands in Orca's active worktree.
25    Inherit,
26    /// Use one Orca worktree selector verbatim (`id:<…>`, `path:<abs>`,
27    /// `name:<…>`, `branch:<…>`).
28    Selector(String),
29}
30
31impl WorktreePolicy {
32    /// Read the `[orca] worktree` config value: `host` (also the empty value
33    /// and the retired `auto` spelling), `inherit`, or a literal Orca
34    /// selector.
35    pub fn from_config(value: &str) -> Self {
36        match value.trim() {
37            "" | "auto" | "host" => Self::Host,
38            "inherit" => Self::Inherit,
39            selector => Self::Selector(selector.to_string()),
40        }
41    }
42}
43
44/// The directory a worktree selector names, when it names one: `path:<abs>`
45/// or the `<worktree-id>::<abs>` shape `ORCA_WORKTREE_ID` carries.
46fn selector_path(selector: &str) -> Option<PathBuf> {
47    let path = selector
48        .strip_prefix("path:")
49        .or_else(|| selector.split_once("::").map(|(_, path)| path))?;
50    let path = PathBuf::from(path);
51    path.is_absolute().then_some(path)
52}
53
54/// The cwd as an absolute, symlink-free path, so a workspace reached through a
55/// symlink (`/tmp` on macOS) still files its tab map under one stable root.
56pub(super) fn absolute(path: &Path) -> PathBuf {
57    if let Ok(canonical) = std::fs::canonicalize(path) {
58        return canonical;
59    }
60    if path.is_absolute() {
61        return path.to_path_buf();
62    }
63    std::env::current_dir()
64        .map(|cwd| cwd.join(path))
65        .unwrap_or_else(|_| path.to_path_buf())
66}
67
68/// What the plugin-facing map needs about one tab, remembered for the
69/// daemon's lifetime: `attach` and `close` see only a [`SessionRef`], so the
70/// spawn-time facts live here and degrade to empty strings after a restart.
71#[derive(Debug, Clone)]
72pub(super) struct TabMemo {
73    pub(super) root: PathBuf,
74    pub(super) role: String,
75    pub(super) session_id: String,
76    pub(super) title: String,
77}
78
79/// One line of `.onlyne/cache/orca-tabs.jsonl`: the tab⇄session mapping a
80/// supervisor script can tail, and the display hook beside `client sessions`
81/// (the board itself does not read it).
82///
83/// The file is append-only and never read back by this backend, because
84/// `backend_ref` in `client.db` stays the authoritative record. Field order is
85/// part of the contract the readers rely on.
86#[derive(Debug, Clone, Serialize)]
87struct TabLine<'a> {
88    pane_key: &'a str,
89    handle: &'a str,
90    task_id: &'a str,
91    session_id: &'a str,
92    role: &'a str,
93    worktree_selector: &'a str,
94    title: &'a str,
95    state: &'a str,
96    updated_at: String,
97}
98
99/// Append one mapping line under `<root>/.onlyne/cache/`.
100fn append_tab_line(root: &Path, line: &TabLine<'_>) -> std::io::Result<()> {
101    let dir = root.join(".onlyne/cache");
102    std::fs::create_dir_all(&dir)?;
103    let mut text =
104        serde_json::to_string(line).map_err(|error| std::io::Error::other(error.to_string()))?;
105    text.push('\n');
106    std::fs::OpenOptions::new()
107        .create(true)
108        .append(true)
109        .open(dir.join("orca-tabs.jsonl"))?
110        .write_all(text.as_bytes())
111}
112
113/// `ORCA_WORKTREE_ID` as the daemon inherited it, `None` when the shell Orca
114/// exported it from is not a tab.
115pub(super) fn host_worktree_env() -> Option<String> {
116    std::env::var("ORCA_WORKTREE_ID")
117        .ok()
118        .map(|value| value.trim().to_string())
119        .filter(|value| !value.is_empty())
120}
121
122impl OrcaBackend {
123    /// The `--worktree` selector for a spawn, or `None` when the tab follows
124    /// Orca's active worktree.
125    ///
126    /// `Host` passes the raw `ORCA_WORKTREE_ID` value — `<worktree-id>::<abs
127    /// path>`, the spelling `orca`'s own CLI resolves a tab's worktree with —
128    /// and degrades to `Inherit` when the daemon started outside an Orca tab.
129    pub(super) fn selector_for(&self) -> Option<String> {
130        match &self.policy {
131            WorktreePolicy::Host => self.host_worktree.clone(),
132            WorktreePolicy::Inherit => None,
133            WorktreePolicy::Selector(selector) => Some(selector.clone()),
134        }
135    }
136
137    /// Record one tab state in the plugin-facing mapping cache.
138    ///
139    /// The file is a display cache under the role workspace, so nothing here
140    /// may fail a spawn, a remint, or a close: a write error only warns.
141    pub(super) fn note(&self, session: &SessionRef, state: &str) {
142        let keys = TabKeys::from_ref(session);
143        let (Some(pane_key), Some(handle)) = (keys.pane_key.as_deref(), keys.handle.as_deref())
144        else {
145            tracing::warn!(
146                task = %session.task_id,
147                "orca terminal has no pane key or handle; the tab map stays unwritten"
148            );
149            return;
150        };
151        let memo = self.tabs.lock().get(pane_key).cloned();
152        let selector = ref_str(session, "selector");
153        let root = memo
154            .as_ref()
155            .map(|memo| memo.root.clone())
156            .or_else(|| selector.as_deref().and_then(selector_path));
157        let Some(root) = root else {
158            tracing::warn!(
159                task = %session.task_id,
160                pane_key,
161                "no workspace root for the orca tab map; the line is skipped"
162            );
163            return;
164        };
165        let line = TabLine {
166            pane_key,
167            handle,
168            task_id: &session.task_id,
169            session_id: memo
170                .as_ref()
171                .map(|memo| memo.session_id.as_str())
172                .unwrap_or_default(),
173            role: memo
174                .as_ref()
175                .map(|memo| memo.role.as_str())
176                .unwrap_or_default(),
177            worktree_selector: selector.as_deref().unwrap_or_default(),
178            title: memo
179                .as_ref()
180                .map(|memo| memo.title.as_str())
181                .unwrap_or_default(),
182            state,
183            updated_at: Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true),
184        };
185        if let Err(error) = append_tab_line(&root, &line) {
186            tracing::warn!(
187                error = %error,
188                task = %session.task_id,
189                "orca tab map line was not written"
190            );
191        }
192    }
193}