Skip to main content

onlyne_client/backend/
select.rs

1use super::*;
2use onlyne_config::{Placement, validate_drive_placement};
3
4pub fn process_env() -> BTreeMap<String, String> {
5    std::env::vars().collect()
6}
7
8fn env_nonempty(env: &BTreeMap<String, String>, key: &str) -> bool {
9    env.get(key).is_some_and(|value| !value.is_empty())
10}
11
12fn orca_host_present(env: &BTreeMap<String, String>) -> bool {
13    env_nonempty(env, "ORCA_PANE_KEY")
14        || env_nonempty(env, "ORCA_TERMINAL_HANDLE")
15        || env_nonempty(env, "ORCA_WORKTREE_ID")
16}
17
18fn zellij_host_present(env: &BTreeMap<String, String>) -> bool {
19    env.contains_key("ZELLIJ")
20}
21
22/// The Tern CLI this client drives when `TERN_COMMAND` names none.
23///
24/// Tern 0.4.5 installs the bundle binary and links it as `~/.local/bin/tern`;
25/// the default stays absolute anyway, so resolution never depends on `PATH`:
26/// a stray entry there can never silently drive this client's panes.
27pub(crate) const TERN_BINARY: &str = "/Applications/Tern.app/Contents/MacOS/tern";
28
29/// The pane this process is already inside, if any: what an absent `placement`
30/// probes, in the plan's order (tern, orca, zellij).
31fn probed_placement(env: &BTreeMap<String, String>) -> Option<Placement> {
32    onlyne_config::PLACEMENT_PROBE_ORDER
33        .into_iter()
34        .find(|placement| host_present(*placement, env))
35}
36
37/// Whether this host's own marker says the process is inside one of its panes.
38///
39/// The probe is about the pane, not about the client's reach: the backend still
40/// asks the host itself whether it answers (`TernBackend::available` runs
41/// `tern ls --json`). A pane marker names where this process sits; it says
42/// nothing about which window a later session may be driven into.
43fn host_present(placement: Placement, env: &BTreeMap<String, String>) -> bool {
44    match placement {
45        Placement::Orca => orca_host_present(env),
46        Placement::Zellij => zellij_host_present(env),
47        Placement::Tern => {
48            env.get("TERM_PROGRAM")
49                .is_some_and(|program| program == "tern")
50                || env_nonempty(env, "TERN_PANE")
51        }
52        Placement::Headless | Placement::External => false,
53    }
54}
55
56/// The host CLI this placement drives, `None` for a placement with no host
57/// binary of its own.
58fn host_binary(placement: Placement, env: &BTreeMap<String, String>) -> Option<String> {
59    let (override_key, fallback): (&str, &str) = match placement {
60        Placement::Orca => ("ORCA_CLI_COMMAND", "orca"),
61        Placement::Zellij => ("ZELLIJ_COMMAND", "zellij"),
62        Placement::Tern => ("TERN_COMMAND", TERN_BINARY),
63        // `headless` and `external` run or drive a command the role config
64        // names, so there is no host binary to report.
65        Placement::Headless | Placement::External => return None,
66    };
67    Some(
68        env.get(override_key)
69            .filter(|value| !value.is_empty())
70            .cloned()
71            .unwrap_or_else(|| fallback.into()),
72    )
73}
74
75/// Resolve the placement this client runs under.
76///
77/// Precedence, highest first: a nonempty `ONLYNE_BACKEND` that names a
78/// placement, the placement this run declared — the workspace config's
79/// `placement` key for `onlyne-client run`, an embedding's own answer
80/// otherwise — then the probe over the pane hosts, and finally `headless`
81/// — the fallback the plan fixes for a machine with no terminal host
82/// (`docs/v2-PLAN.md` §"驱动与放置").
83///
84/// An explicit name that matches nothing is refused by name. It is never
85/// silently replaced by the probe, because a cluster running under a placement
86/// nobody chose is the failure this split exists to remove.
87pub fn detect_placement(
88    env: &BTreeMap<String, String>,
89    declared: Option<SessionPlacement>,
90) -> Result<PlacementDetection> {
91    let explicit = env
92        .get("ONLYNE_BACKEND")
93        .map(|value| value.trim().to_string())
94        .filter(|value| !value.is_empty() && !value.eq_ignore_ascii_case("auto"));
95    if let Some(name) = &explicit {
96        let Some(placement) = SessionPlacement::parse(name) else {
97            return Err(UnknownPlacement(name.clone()).into());
98        };
99        return Ok(PlacementDetection {
100            placement,
101            source: SelectionSource::Explicit,
102            explicit: Some(name.clone()),
103        });
104    }
105    if let Some(placement) = declared {
106        return Ok(PlacementDetection {
107            placement,
108            source: SelectionSource::Declared,
109            explicit: None,
110        });
111    }
112    let probed = probed_placement(env);
113    Ok(PlacementDetection {
114        placement: SessionPlacement::Named(probed.unwrap_or(Placement::Headless)),
115        source: if probed.is_some() {
116            SelectionSource::Probe
117        } else {
118            SelectionSource::Fallback
119        },
120        explicit: None,
121    })
122}
123
124/// `onlyne-client doctor`: the placement this machine resolves, as JSON, with
125/// exit 0 for every answer — a refusal included, which the caller reads as the
126/// `refusal` line rather than as a process failure.
127pub fn doctor_report(env: &BTreeMap<String, String>) -> Value {
128    let detected = detect_placement(env, None);
129    let found = detected.as_ref().ok();
130    let placement = found.map(|detected| detected.placement.as_str());
131    let selection = found.map(|detected| match detected.source {
132        SelectionSource::Explicit => "explicit",
133        SelectionSource::Declared => "declared",
134        SelectionSource::Probe => "probe",
135        SelectionSource::Fallback => "fallback",
136    });
137    let binary = found
138        .and_then(|detected| detected.placement.named())
139        .and_then(|named| host_binary(named, env));
140    let mut report = serde_json::json!({
141        "placement": placement,
142        "placement_selection": selection,
143        "binary": binary,
144        "explicit": found.and_then(|detected| detected.explicit.clone()),
145    });
146    if let Err(error) = &detected {
147        report["refusal"] = Value::String(error.to_string());
148    }
149    report
150}
151
152/// Build the backend one `drive × placement` pair selects.
153///
154/// The drive is a property of the runtime and arrives from the role's spec with
155/// `welcome`; the placement is a property of this machine. Together they are
156/// the plan's four rows:
157///
158/// | drive | placement | who starts the runtime |
159/// |---|---|---|
160/// | plugin | orca / zellij / tern | the client, in that pane; the plugin dials back |
161/// | plugin | headless | the client, in the background; the plugin dials back |
162/// | plugin | external | nobody: the resident runtime dials in |
163/// | acp | headless | the client, as a child it speaks ACP to on stdio |
164/// | exec | any placement | the client, which reads the exit code |
165///
166/// The pair is validated first: `acp` pairs only with `headless`, because stdio
167/// carries the ACP channel and cannot also be a pane's terminal.
168///
169/// `fake` is the in-process runtime the test suite selects through
170/// `ONLYNE_BACKEND`; it ignores the drive, because it owns no process at all.
171pub fn backend_for(
172    drive: onlyne_config::Drive,
173    placement: SessionPlacement,
174    runner: Arc<dyn Runner>,
175    policy: WorktreePolicy,
176    acp: &AcpOptions,
177) -> Result<Box<dyn SessionBackend>> {
178    if let Some(named) = placement.named() {
179        validate_drive_placement(drive, named).map_err(|message| anyhow::anyhow!("{message}"))?;
180    }
181    Ok(match placement {
182        SessionPlacement::Fake => Box::new(fake::FakeBackend::new()),
183        SessionPlacement::Named(Placement::Orca) => {
184            Box::new(orca::OrcaBackend::with_policy(runner, policy))
185        }
186        SessionPlacement::Named(Placement::Zellij) => Box::new(zellij::ZellijBackend::new(runner)),
187        SessionPlacement::Named(Placement::Tern) => Box::new(tern::TernBackend::new(runner)),
188        SessionPlacement::Named(Placement::Headless) => match drive {
189            onlyne_config::Drive::Acp => Box::new(acp::AcpBackend::new(acp.clone())),
190            onlyne_config::Drive::Plugin | onlyne_config::Drive::Exec => {
191                Box::new(exec::ExecBackend::new())
192            }
193        },
194        SessionPlacement::Named(Placement::External) => match drive {
195            onlyne_config::Drive::Plugin => Box::new(external::ExternalBackend::new()),
196            onlyne_config::Drive::Acp | onlyne_config::Drive::Exec => {
197                Box::new(exec::ExecBackend::new())
198            }
199        },
200    })
201}