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}