Skip to main content

onlyne_client/backend/
spec.rs

1use super::*;
2
3#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
4pub struct Capabilities {
5    pub spawn: bool,
6    pub attach: bool,
7    pub probe: bool,
8    pub close: bool,
9    pub focus: bool,
10    pub rename: bool,
11}
12
13#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
14pub struct SpawnSpec {
15    pub cwd: PathBuf,
16    pub task_id: String,
17    pub command: Vec<String>,
18    #[serde(default)]
19    pub env: BTreeMap<String, String>,
20    /// The tools-mount token this client minted for the session being opened.
21    ///
22    /// A session the client drives itself has no plugin to carry its
23    /// obligations, so this token is what binds the `onlyne mcp` child back to
24    /// this one session (`docs/v2-CONTRACT.md` §3b). It travels here and not
25    /// inside `env`, because `env` is the agent process's own environment: a
26    /// capability the model can print is no capability, and the mount's env
27    /// belongs to the tool child alone.
28    #[serde(default)]
29    pub tools_token: String,
30    /// The role's control-plane prose, as the slice `welcome` brought it.
31    ///
32    /// A runtime with a system-prompt extension point is handed this through
33    /// that point. An ACP session has none, so this is what the client writes
34    /// into the workspace instruction file before the session opens
35    /// (`AGENTS.md` §12).
36    #[serde(default)]
37    pub prose: String,
38    #[serde(default)]
39    pub focus: Option<bool>,
40    #[serde(default)]
41    pub placement: Option<PanePlacement>,
42    #[serde(default)]
43    pub rename: Option<String>,
44}
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
47#[serde(rename_all = "snake_case")]
48pub enum SplitDirection {
49    Right,
50    Down,
51}
52
53#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
54pub struct PanePlacement {
55    pub direction: SplitDirection,
56    pub ratio: f64,
57}
58
59impl PanePlacement {
60    /// A split that brings the pane count to a power of two goes right.
61    /// Every other split goes down. Ratio is always 0.5.
62    pub fn from_pane_count(pane_count: usize) -> Self {
63        let direction = if (pane_count + 1).is_power_of_two() {
64            SplitDirection::Right
65        } else {
66            SplitDirection::Down
67        };
68        Self {
69            direction,
70            ratio: 0.5,
71        }
72    }
73}
74
75/// Where a session of this client runs: the placement the machine resolved, or
76/// the in-process `fake` runtime.
77///
78/// Placement is a property of the machine and comes from the workspace
79/// `config.toml` or from `ONLYNE_BACKEND`; a drive is a property of the runtime
80/// and comes from the role's spec. `fake` is the backend that owns no process
81/// and needs no external tool, which is how the scenario suite and every e2e
82/// case run a real client on a machine with no terminal host at all. Only the
83/// environment and an embedding that resolved the placement itself can name it:
84/// a workspace config names a placement, and a runtime that starts nothing is
85/// not one an operator should reach by typo.
86#[derive(Debug, Clone, Copy, PartialEq, Eq)]
87pub enum SessionPlacement {
88    Named(onlyne_config::Placement),
89    Fake,
90}
91
92impl SessionPlacement {
93    /// Two pre-split spellings are accepted here and nowhere else: `fake`
94    /// selects the in-process test runtime, and `exec` is the name a v1
95    /// `ONLYNE_BACKEND` used for a session the client runs in the background,
96    /// which is the `headless` placement. A workspace config names neither: its
97    /// key is `placement`, and the five names that key accepts are the ones
98    /// `onlyne_config::PLACEMENT_NAMES` lists.
99    pub fn parse(name: &str) -> Option<Self> {
100        let trimmed = name.trim();
101        if trimmed.eq_ignore_ascii_case("fake") {
102            return Some(Self::Fake);
103        }
104        if trimmed.eq_ignore_ascii_case("exec") {
105            return Some(Self::Named(onlyne_config::Placement::Headless));
106        }
107        onlyne_config::Placement::parse(trimmed).map(Self::Named)
108    }
109
110    pub fn as_str(self) -> &'static str {
111        match self {
112            Self::Named(placement) => placement.as_str(),
113            Self::Fake => "fake",
114        }
115    }
116
117    /// The placement itself, `None` for the in-process runtime, which names no
118    /// place on the machine.
119    pub fn named(self) -> Option<onlyne_config::Placement> {
120        match self {
121            Self::Named(placement) => Some(placement),
122            Self::Fake => None,
123        }
124    }
125}
126
127impl std::fmt::Display for SessionPlacement {
128    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
129        f.write_str(self.as_str())
130    }
131}
132
133/// The refusal an explicit `placement` name that matches nothing gets. It names
134/// the accepted set and where each source sits in the precedence, because the
135/// miss it answers is a typo the operator cannot otherwise see.
136pub fn unknown_placement(name: &str) -> String {
137    format!(
138        "onlyne: `{name}` is not a placement; accepted: {} \
139         (absent probes {}, then headless; ONLYNE_BACKEND wins over the workspace's `placement` key)",
140        onlyne_config::PLACEMENT_NAMES,
141        onlyne_config::PLACEMENT_PROBE_ORDER
142            .map(|placement| placement.as_str())
143            .join(", ")
144    )
145}
146
147/// An explicit placement name this client does not know. `onlyne-client run`
148/// answers it with exit 5, the code for "no host the client could use".
149#[derive(Debug, Clone, PartialEq, Eq)]
150pub struct UnknownPlacement(pub String);
151
152impl std::fmt::Display for UnknownPlacement {
153    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
154        f.write_str(&unknown_placement(&self.0))
155    }
156}
157
158impl std::error::Error for UnknownPlacement {}
159
160/// Which of the four sources answered the placement question. `doctor` reports
161/// the word, because "which machine fact chose this" is the question an
162/// operator actually has.
163#[derive(Debug, Clone, Copy, PartialEq, Eq)]
164pub enum SelectionSource {
165    /// A nonempty `ONLYNE_BACKEND` named it.
166    Explicit,
167    /// The run itself declared it: the role workspace's `config.toml`
168    /// `placement` key, or what an embedding passed to `ClientInit`.
169    Declared,
170    /// A pane host answered the probe.
171    Probe,
172    /// No host answered, so the placement is `headless`.
173    Fallback,
174}
175
176/// The placement this client resolved, and where it came from.
177#[derive(Debug, Clone, PartialEq, Eq)]
178pub struct PlacementDetection {
179    pub placement: SessionPlacement,
180    pub source: SelectionSource,
181    /// The raw `ONLYNE_BACKEND` value when it named the placement, so a reader
182    /// can see the spelling that won.
183    pub explicit: Option<String>,
184}
185
186#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
187pub struct SessionRef {
188    pub task_id: String,
189    pub backend: String,
190    pub backend_ref: Value,
191    pub generation: u64,
192}
193
194#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
195pub struct ResourceProbe {
196    pub alive: bool,
197    pub attached: bool,
198    pub detail: Option<Value>,
199}
200
201#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
202#[serde(rename_all = "snake_case")]
203pub enum CloseReason {
204    Completed,
205    Cancelled,
206    Fault,
207    Shutdown,
208    Replaced,
209    Operator,
210}
211
212/// One terminal fact a self-driven backend observed for itself.
213///
214/// A session served by an adapter reports its own ending, and the client only
215/// has to write it down. A backend that owns its agent has no reporter, so it
216/// states the fact here: which task ended, how, and what the receiving role
217/// should read as its closing line.
218#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
219pub struct SessionOutcome {
220    pub task_id: String,
221    /// How the turn ended, when ending the turn *is* the fact the client
222    /// records.
223    ///
224    /// `None` is the ordinary ending of a session whose obligations travel as
225    /// tool calls: the agent stopped asking for work (`end_turn`) and this
226    /// backend has no way to know whether the model reported the task done,
227    /// because the completion arrives on the session's own tools connection
228    /// rather than through this process's pipe. Ending a turn without a
229    /// completion is the state the client's turn-end rule owns
230    /// (`docs/v2-CONTRACT.md` §3c): it nudges once, and the *second* ending is
231    /// what settles the delivery. `Some` is a standing this backend did observe
232    /// for itself — a cancelled, refused, or dead turn — and it settles the
233    /// delivery where it lands.
234    pub outcome: Option<TaskState>,
235    /// The agent's closing text, already stripped of the status markers an agent
236    /// stamps into its own stream. Becomes the completion head.
237    pub head: Option<String>,
238    /// Fault detail for the ledger on a failure. An agent process that died
239    /// mid-turn names its exit status and the tail of its stderr here.
240    pub note: Option<String>,
241    /// One-line summary of the permission asks this client refused during the
242    /// turn, `None` when the agent asked for nothing. A refusal is recorded on
243    /// the session row as a fault and settles nothing by itself: the turn kept
244    /// running without the thing the agent wanted.
245    pub refusals: Option<String>,
246}