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 pane this process is already inside, if any: what an absent `placement`
23/// probes, in the plan's order (orca, zellij).
24fn probed_placement(env: &BTreeMap<String, String>) -> Option<Placement> {
25    if orca_host_present(env) {
26        Some(Placement::Orca)
27    } else if zellij_host_present(env) {
28        Some(Placement::Zellij)
29    } else {
30        None
31    }
32}
33
34/// Resolve the placement this client runs under.
35///
36/// Precedence, highest first: a nonempty `ONLYNE_BACKEND` that names a
37/// placement, the placement this run declared — the workspace config's
38/// `placement` key for `onlyne-client run`, an embedding's own answer
39/// otherwise — then the probe over the pane hosts, and finally `headless`
40/// — the fallback the plan fixes for a machine with no terminal host
41/// (`docs/v2-PLAN.md` §"驱动与放置").
42///
43/// An explicit name that matches nothing is refused by name. It is never
44/// silently replaced by the probe, because a cluster running under a placement
45/// nobody chose is the failure this split exists to remove.
46pub fn detect_placement(
47    env: &BTreeMap<String, String>,
48    declared: Option<SessionPlacement>,
49) -> Result<PlacementDetection> {
50    let explicit = env
51        .get("ONLYNE_BACKEND")
52        .map(|value| value.trim().to_string())
53        .filter(|value| !value.is_empty() && !value.eq_ignore_ascii_case("auto"));
54    if let Some(name) = &explicit {
55        let Some(placement) = SessionPlacement::parse(name) else {
56            return Err(UnknownPlacement(name.clone()).into());
57        };
58        return Ok(PlacementDetection {
59            placement,
60            source: SelectionSource::Explicit,
61            explicit: Some(name.clone()),
62        });
63    }
64    if let Some(placement) = declared {
65        return Ok(PlacementDetection {
66            placement,
67            source: SelectionSource::Declared,
68            explicit: None,
69        });
70    }
71    let probed = probed_placement(env);
72    Ok(PlacementDetection {
73        placement: SessionPlacement::Named(probed.unwrap_or(Placement::Headless)),
74        source: if probed.is_some() {
75            SelectionSource::Probe
76        } else {
77            SelectionSource::Fallback
78        },
79        explicit: None,
80    })
81}
82
83/// `onlyne-client doctor`: the placement this machine resolves, as JSON, with
84/// exit 0 for every answer — a refusal included, which the caller reads as the
85/// `refusal` line rather than as a process failure.
86pub fn doctor_report(env: &BTreeMap<String, String>) -> Value {
87    let detected = detect_placement(env, None);
88    let found = detected.as_ref().ok();
89    let placement = found.map(|detected| detected.placement.as_str());
90    let selection = found.map(|detected| match detected.source {
91        SelectionSource::Explicit => "explicit",
92        SelectionSource::Declared => "declared",
93        SelectionSource::Probe => "probe",
94        SelectionSource::Fallback => "fallback",
95    });
96    let binary = match placement {
97        Some("orca") => Some(
98            env.get("ORCA_CLI_COMMAND")
99                .filter(|value| !value.is_empty())
100                .cloned()
101                .unwrap_or_else(|| "orca".into()),
102        ),
103        Some("zellij") => Some(
104            env.get("ZELLIJ_COMMAND")
105                .filter(|value| !value.is_empty())
106                .cloned()
107                .unwrap_or_else(|| "zellij".into()),
108        ),
109        // `headless`, `external` and `fake` run or drive a command the role
110        // config names, so there is no host binary to report.
111        _ => None,
112    };
113    let mut report = serde_json::json!({
114        "placement": placement,
115        "placement_selection": selection,
116        "binary": binary,
117        "explicit": found.and_then(|detected| detected.explicit.clone()),
118    });
119    if let Err(error) = &detected {
120        report["refusal"] = Value::String(error.to_string());
121    }
122    report
123}
124
125/// Build the backend one `drive × placement` pair selects.
126///
127/// The drive is a property of the runtime and arrives from the role's spec with
128/// `welcome`; the placement is a property of this machine. Together they are
129/// the plan's four rows:
130///
131/// | drive | placement | who starts the runtime |
132/// |---|---|---|
133/// | plugin | orca / zellij | the client, in that pane; the plugin dials back |
134/// | plugin | headless | the client, in the background; the plugin dials back |
135/// | plugin | external | nobody: the resident runtime dials in |
136/// | acp | headless | the client, as a child it speaks ACP to on stdio |
137/// | exec | any placement | the client, which reads the exit code |
138///
139/// The pair is validated first: `acp` pairs only with `headless`, because stdio
140/// carries the ACP channel and cannot also be a pane's terminal.
141///
142/// `fake` is the in-process runtime the test suite selects through
143/// `ONLYNE_BACKEND`; it ignores the drive, because it owns no process at all.
144pub fn backend_for(
145    drive: onlyne_config::Drive,
146    placement: SessionPlacement,
147    runner: Arc<dyn Runner>,
148    policy: WorktreePolicy,
149    acp: &AcpOptions,
150) -> Result<Box<dyn SessionBackend>> {
151    if let Some(named) = placement.named() {
152        validate_drive_placement(drive, named).map_err(|message| anyhow::anyhow!("{message}"))?;
153    }
154    Ok(match placement {
155        SessionPlacement::Fake => Box::new(fake::FakeBackend::new()),
156        SessionPlacement::Named(Placement::Orca) => {
157            Box::new(orca::OrcaBackend::with_policy(runner, policy))
158        }
159        SessionPlacement::Named(Placement::Zellij) => Box::new(zellij::ZellijBackend::new(runner)),
160        SessionPlacement::Named(Placement::Headless) => match drive {
161            onlyne_config::Drive::Acp => Box::new(acp::AcpBackend::new(acp.clone())),
162            onlyne_config::Drive::Plugin | onlyne_config::Drive::Exec => {
163                Box::new(exec::ExecBackend::new())
164            }
165        },
166        SessionPlacement::Named(Placement::External) => match drive {
167            onlyne_config::Drive::Plugin => Box::new(external::ExternalBackend::new()),
168            onlyne_config::Drive::Acp | onlyne_config::Drive::Exec => {
169                Box::new(exec::ExecBackend::new())
170            }
171        },
172    })
173}