onlyne_client/backend/tern/session.rs
1//! The session layer: the backend's own type and its [`SessionBackend`]
2//! implementation.
3
4use super::cli::default_command;
5use super::policy::{Site, TernRef, session_label, split_word};
6use crate::backend::*;
7use std::collections::BTreeMap;
8use std::sync::Arc;
9
10pub struct TernBackend {
11 pub(super) runner: Arc<dyn Runner>,
12 pub(super) command: String,
13 pub(super) env: BTreeMap<String, String>,
14}
15
16impl TernBackend {
17 pub fn new(runner: Arc<dyn Runner>) -> Self {
18 Self::with_env(runner, process_env())
19 }
20
21 pub fn with_env(runner: Arc<dyn Runner>, env: BTreeMap<String, String>) -> Self {
22 let command = env
23 .get("TERN_COMMAND")
24 .cloned()
25 .filter(|value| !value.is_empty())
26 .unwrap_or_else(default_command);
27 Self {
28 runner,
29 command,
30 env,
31 }
32 }
33}
34
35impl SessionBackend for TernBackend {
36 fn name(&self) -> &'static str {
37 "tern"
38 }
39
40 fn capabilities(&self) -> Capabilities {
41 Capabilities {
42 spawn: true,
43 attach: true,
44 probe: true,
45 close: true,
46 focus: true,
47 // `tern rename BLOCK NAME` renames the block's *tab*, measured: a
48 // block id renames the tab that holds it, and a tab id is refused
49 // with `no block is called`. Renaming one session's pane would
50 // therefore rename every pane in that role's tab, so this backend
51 // answers unsupported rather than do it.
52 rename: false,
53 }
54 }
55
56 fn available(&self) -> Result<bool> {
57 // The only honest test: run the read every other call here begins with.
58 // A window key in the environment proves the client is inside a pane,
59 // not that the binary answers — and a `PATH` `tern` would fail this
60 // where a spawned pane's `ls` would too.
61 Ok(self.json(vec!["ls".into(), "--json".into()]).is_ok())
62 }
63
64 fn spawn(&self, spec: SpawnSpec) -> Result<SessionRef> {
65 if spec.command.is_empty() {
66 anyhow::bail!("tern spawn requires a command");
67 }
68 let label = session_label(&spec);
69 let (session_id, tab_id, base_pane, direction, pane_id) = match self.role_site(&spec)? {
70 // A launch already holds the agent: it is its own base, and the
71 // direction keeps the word a first split beside it would take,
72 // so the field reads the shape every ref carries. A found tab
73 // needs the split, and its block count picks the default
74 // direction as it did for herdr — a split bringing the count to
75 // a power of two goes right, every other one down. Tern takes no
76 // ratio, so the placement contributes its direction and nothing
77 // else.
78 Site::Launched {
79 session_id,
80 tab_id,
81 ref pane_id,
82 } => (
83 session_id,
84 tab_id,
85 pane_id.clone(),
86 split_word(PanePlacement::from_pane_count(0).direction),
87 pane_id.clone(),
88 ),
89 Site::Found {
90 session_id,
91 tab_id,
92 base_pane,
93 pane_count,
94 } => {
95 // Tern takes no ratio, so the placement contributes its
96 // direction and nothing else. The pane count still decides
97 // the default direction, as it did for herdr: a split
98 // bringing the count to a power of two goes right, every
99 // other one down.
100 let placement = spec
101 .placement
102 .unwrap_or_else(|| PanePlacement::from_pane_count(pane_count));
103 tracing::info!(
104 pane_count,
105 direction = split_word(placement.direction),
106 "tern block split (tern takes no split ratio)"
107 );
108 let pane_id =
109 self.split_and_start(&base_pane, &spec, placement, &session_id, &tab_id)?;
110 (
111 session_id,
112 tab_id,
113 base_pane,
114 split_word(placement.direction),
115 pane_id,
116 )
117 }
118 };
119 Ok(SessionRef {
120 task_id: spec.task_id.clone(),
121 backend: self.name().into(),
122 backend_ref: TernRef {
123 session_id,
124 tab_id,
125 base_pane,
126 pane_id,
127 session_label: label,
128 split_direction: direction.to_string(),
129 }
130 .to_value(),
131 generation: 1,
132 })
133 }
134
135 fn attach(&self, session: &SessionRef) -> Result<SessionRef> {
136 let probe = self.probe(session)?;
137 if !probe.alive {
138 anyhow::bail!("tern block is gone");
139 }
140 Ok(session.clone())
141 }
142
143 fn probe(&self, session: &SessionRef) -> Result<ResourceProbe> {
144 let reference = TernRef::from_session(session)?;
145 // A listing that cannot be read is a probe that could not answer, not
146 // a verdict: the block's own state is unknown, so it reports the
147 // failure rather than claiming the pane died.
148 let listing = self.listing()?;
149 let Some((block, tab, host)) = listing.block(&reference.pane_id) else {
150 // The block is in no tab of any session: gone, or detached by the
151 // daemon. Both read the same to this backend, which can address
152 // neither.
153 return Ok(ResourceProbe {
154 alive: false,
155 attached: false,
156 detail: Some(serde_json::json!({
157 "pane_id": reference.pane_id,
158 "error": "block is in no tab",
159 })),
160 });
161 };
162 // Liveness from the block's own row: `exited` is null while the
163 // program runs and holds an exit code once it returns, and `live` says
164 // whether the daemon still holds the pty. A `--keep-open` block whose
165 // command has ended stays listed with `exited` set — the pane is
166 // addressable, and the session that owned it is over. So `exited`
167 // ends a session and `live` is the fallback for a build that reports
168 // one without the other.
169 let alive = block.exited.is_none() && block.live;
170 let mut detail = serde_json::json!({
171 "pane": {
172 "id": block.id,
173 "title": block.title,
174 "cwd": block.cwd,
175 "program": block.program,
176 "command": block.command,
177 "exited": block.exited,
178 "keep_open": block.keep_open,
179 "focused": block.focused,
180 "live": block.live,
181 },
182 "tab_id": tab.id,
183 "session_id": host.id,
184 "session_label": host.name,
185 });
186 if let Some(process) = self.process_of(&reference.pane_id) {
187 if let Some(map) = detail.as_object_mut() {
188 map.insert("process".into(), process);
189 }
190 }
191 Ok(ResourceProbe {
192 alive,
193 // A block still listed is one the daemon holds, so a live one is
194 // attached to a pty. A block whose program exited under
195 // `--keep-open` is addressable but holds nothing: attached follows
196 // `live`, which is what separates the two.
197 attached: block.live,
198 detail: Some(detail),
199 })
200 }
201
202 fn close(&self, session: &SessionRef, reason: CloseReason, force: bool) -> Result<()> {
203 // `tern close` has no force flag and no reason; both close paths are
204 // the same command.
205 tracing::debug!(task = %session.task_id, ?reason, force, "closing tern block");
206 let reference = TernRef::from_session(session)?;
207 match self.json(vec![
208 "close".into(),
209 reference.pane_id.clone(),
210 "--json".into(),
211 ]) {
212 Ok(_) => Ok(()),
213 // Measured: a closed block is refused with `no block is called
214 // \`N\`` and exit 1, with no JSON body to carry a code. Closing
215 // again is a no-op, which is what lets the client end a session
216 // twice without the second end failing.
217 Err(error) if self.is_gone(&error) => {
218 tracing::debug!(
219 task = %session.task_id,
220 pane = %reference.pane_id,
221 "tern block already closed"
222 );
223 Ok(())
224 }
225 Err(error) => Err(error),
226 }
227 }
228
229 fn rename(&self, _session: &SessionRef, _title: &str) -> Result<()> {
230 // `tern rename BLOCK NAME` renames the block's tab, so one session's
231 // title would land on every pane in the role's tab. Tern offers no
232 // block title of its own — the listing's `title` is the pane's
233 // working directory or its program — so this is unsupported, and the
234 // capability says so.
235 Err(unsupported(
236 self.name(),
237 "rename",
238 "tern renames a block's tab, so renaming one session's block would rename the \
239 whole role tab",
240 ))
241 }
242
243 fn focus(&self, session: &SessionRef) -> Result<()> {
244 let reference = TernRef::from_session(session)?;
245 let listing = self.listing()?;
246 if let Some(site) = self.focus_here(&listing) {
247 if site.pane_id == reference.pane_id
248 && site.tab_id == reference.tab_id
249 && site.session_id == reference.session_id
250 {
251 return Ok(());
252 }
253 }
254 self.focus_block(&reference.pane_id)?;
255 // Confirm the block took focus, naming the one that holds it. The
256 // listing can drift when an operator closes or moves blocks, and a
257 // focus that lands elsewhere sends the operator's keyboard to another
258 // session — a wrong block holding focus is reported, not passed off as
259 // success.
260 let after = self.listing()?;
261 match self.focus_here(&after) {
262 Some(site) if site.pane_id == reference.pane_id => Ok(()),
263 Some(site) => Err(anyhow::anyhow!(
264 "tern left block {} unfocused (focused block {})",
265 reference.pane_id,
266 site.pane_id
267 )),
268 None => Err(anyhow::anyhow!(
269 "tern left block {} unfocused (no block holds focus)",
270 reference.pane_id
271 )),
272 }
273 }
274}