Skip to main content

onetaskgraph_core/subprocess/
serve.rs

1//! The plugin's half of the protocol, for a source this build already has.
2//!
3//! This is the reference implementation of the other side of
4//! `docs/plugin-protocol.md`, and it exists for two reasons. It is what the
5//! `onetaskgraph-source` program runs, so any registered plugin can be hosted in a child
6//! process without being rewritten. And it is what makes the journeys real: the shared
7//! fixture table configures a source through it, so every journey in the suite runs a
8//! second time over a genuine pipe to a genuine second process rather than over a double
9//! standing in for one.
10//!
11//! It answers strictly in order, which §1.1 names as the simpler correct choice, and it
12//! never crashes on a bad line from the other side (§6.3).
13
14use std::collections::BTreeMap;
15use std::io::{BufRead, Write};
16
17use onetaskgraph_plugin_api::{SecretResolver, SourceError, SourceName, TaskSource};
18use secrecy::SecretString;
19use serde::Deserialize;
20use serde_json::{Value, json};
21
22use super::connection::{Line, MAX_LINE, read_line};
23use super::wire::{
24    DeleteParams, DependencyParams, HandshakePluginKind, IdParams, InitializeParams,
25    InitializeResult, LabelParams, PROTOCOL_VERSION, ProjectQueryParams, ProjectWriteParams,
26    Request, Response, TaskQueryParams, TaskWriteParams,
27};
28use crate::registry::PluginKind;
29
30/// What this reference host needs in the `config` the handshake hands it.
31///
32/// The protocol says `config` is "this source's `config:` block, verbatim" and says
33/// nothing about its contents, because they are the plugin's own business. This host's
34/// business is to run one of *this build's* registered plugins, so its settings name
35/// which one and hand over that plugin's block untouched.
36#[derive(Debug, Clone, Deserialize)]
37struct HostedSettings {
38    /// The registered plugin kind to build.
39    kind: PluginKind,
40    /// That plugin's own `config:` block.
41    #[serde(default)]
42    config: Value,
43}
44
45/// Serve one connection until the engine closes its input.
46///
47/// # Errors
48///
49/// Returns the underlying [`std::io::Error`] when this process can no longer read its
50/// input or write its output. Everything else — an unusable configuration, a request this
51/// version has no method for, a line that is not JSON — is answered on the wire or
52/// reported on standard error, because a plugin that exits on a bad line takes every
53/// other in-flight request with it (§6.3).
54pub async fn serve(input: impl BufRead, output: impl Write) -> std::io::Result<()> {
55    serve_kind(input, output, None).await
56}
57
58/// Serve one connection as the registered plugin `kind`.
59///
60/// Unlike [`serve`], the initialize request's `config` is handed directly to that plugin;
61/// the process command has already selected the kind it hosts.
62pub async fn serve_plugin(
63    input: impl BufRead,
64    output: impl Write,
65    kind: PluginKind,
66) -> std::io::Result<()> {
67    serve_kind(input, output, Some(kind)).await
68}
69
70async fn serve_kind(
71    mut input: impl BufRead,
72    mut output: impl Write,
73    kind: Option<PluginKind>,
74) -> std::io::Result<()> {
75    let mut source: Option<Box<dyn TaskSource>> = None;
76    loop {
77        let line = match read_line(&mut input) {
78            Line::Read(line) => line,
79            Line::Ended => return Ok(()),
80            Line::Failed(error) => return Err(error),
81            // Nothing after an unterminated line can be framed — the rest of it would be
82            // read as further requests it is not — so this side says why and stops rather
83            // than answering questions nobody asked. The engine sees the closed stream.
84            Line::TooLong => {
85                eprintln!(
86                    "onetaskgraph-source: a request ran past {MAX_LINE} bytes without \
87                     ending its line; closing the connection"
88                );
89                return Ok(());
90            }
91        };
92        if line.trim().is_empty() {
93            continue;
94        }
95        let Some(id) = addressed(&line) else {
96            eprintln!("onetaskgraph-source: ignoring a line with no request id: {line}");
97            continue;
98        };
99        let response = match serde_json::from_str::<Request>(&line) {
100            Ok(request) => answer(&mut source, request, kind).await,
101            Err(error) => Response::failed(
102                id,
103                SourceError::Malformed {
104                    message: format!("that is not a request envelope: {error}"),
105                },
106            ),
107        };
108        let finished = ended_the_connection(&response);
109        writeln!(
110            output,
111            "{}",
112            // A response is built from contract types that all serialize.
113            serde_json::to_string(&response).expect("a response is plain data")
114        )?;
115        output.flush()?;
116        if finished {
117            return Ok(());
118        }
119    }
120}
121
122/// The `id` a line is addressed with, if it has one at all.
123///
124/// Read from the raw JSON rather than from a parsed [`Request`] because §6.3 turns on
125/// exactly this difference: a request this side cannot otherwise understand is *answered*
126/// when an id can be associated with it, and only reported on standard error when one
127/// cannot.
128fn addressed(line: &str) -> Option<String> {
129    serde_json::from_str::<Value>(line)
130        .ok()?
131        .get("id")?
132        .as_str()
133        .map(str::to_owned)
134}
135
136/// Whether this response is a refusal §6.2 says the plugin exits after.
137fn ended_the_connection(response: &Response) -> bool {
138    matches!(
139        response.error.as_ref(),
140        Some(SourceError::Config { message }) if message.starts_with(VERSION_REFUSAL)
141    )
142}
143
144/// The opening of the one message §6.2 spells out, so the refusal and the exit that
145/// follows it cannot drift apart.
146const VERSION_REFUSAL: &str = "protocol version ";
147
148/// Answer one well-formed request.
149async fn answer(
150    source: &mut Option<Box<dyn TaskSource>>,
151    request: Request,
152    kind: Option<PluginKind>,
153) -> Response {
154    let Request { id, method, params } = request;
155    if method == "initialize" {
156        return match source {
157            Some(_) => Response::failed(
158                id,
159                SourceError::Malformed {
160                    message: "this connection was already initialized".to_owned(),
161                },
162            ),
163            None => initialize(source, id, params, kind),
164        };
165    }
166    let Some(built) = source.as_deref() else {
167        return Response::failed(
168            id,
169            SourceError::Malformed {
170                message: format!("{method} arrived before the handshake"),
171            },
172        );
173    };
174    match dispatch(built, &method, params).await {
175        Ok(result) => Response::ok(id, result),
176        Err(error) => Response::failed(id, error),
177    }
178}
179
180/// The handshake (§3), including the version refusal §6.2 spells out.
181fn initialize(
182    source: &mut Option<Box<dyn TaskSource>>,
183    id: String,
184    params: Value,
185    kind: Option<PluginKind>,
186) -> Response {
187    let params: InitializeParams = match serde_json::from_value(params) {
188        Ok(params) => params,
189        Err(error) => {
190            return Response::failed(
191                id,
192                SourceError::Config {
193                    message: format!("that is not an initialize request: {error}"),
194                },
195            );
196        }
197    };
198    if params.protocol_version != PROTOCOL_VERSION {
199        return Response::failed(
200            id,
201            SourceError::Config {
202                message: format!(
203                    "{VERSION_REFUSAL}{} is not supported by this plugin; it speaks \
204                     version {PROTOCOL_VERSION}",
205                    params.protocol_version
206                ),
207            },
208        );
209    }
210    match build(&params, kind) {
211        Ok(built) => {
212            let kind = match HandshakePluginKind::new(built.kind()) {
213                Ok(kind) => kind,
214                Err(error) => {
215                    return Response::failed(
216                        id,
217                        SourceError::Malformed {
218                            message: format!("the hosted plugin reported an invalid kind: {error}"),
219                        },
220                    );
221                }
222            };
223            let result = InitializeResult {
224                protocol_version: Some(PROTOCOL_VERSION),
225                kind,
226                capabilities: built.capabilities(),
227                writes: Some(built.writes()),
228            };
229            *source = Some(built);
230            // An `InitializeResult` is a string, an integer and a `Capabilities`.
231            Response::ok(
232                id,
233                serde_json::to_value(&result).expect("a result is plain data"),
234            )
235        }
236        Err(error) => Response::failed(id, error),
237    }
238}
239
240/// Build the registered plugin these settings name.
241fn build(
242    params: &InitializeParams,
243    selected: Option<PluginKind>,
244) -> Result<Box<dyn TaskSource>, SourceError> {
245    let (kind, config) = match selected {
246        Some(kind) => (kind, &params.config),
247        None => {
248            let settings: HostedSettings = serde_json::from_value(params.config.clone()).map_err(
249                |error| SourceError::Config {
250                    message: format!(
251                        "this host serves a plugin of this build, and its settings must name one \
252                         as {{\"kind\": …, \"config\": …}}: {error}"
253                    ),
254                },
255            )?;
256            return build_plugin(params, settings.kind, &settings.config);
257        }
258    };
259    build_plugin(params, kind, config)
260}
261
262fn build_plugin(
263    params: &InitializeParams,
264    kind: PluginKind,
265    config: &Value,
266) -> Result<Box<dyn TaskSource>, SourceError> {
267    let name = SourceName::new(params.source_name.clone())?;
268    kind.plugin()
269        .build(&name, config, &Handshake(&params.secrets))
270}
271
272/// The credentials the handshake forwarded, and nothing else.
273///
274/// §3.1 is the whole of this type: a plugin must not read credentials from its own
275/// process environment, because doing so makes it work on a host where the engine's own
276/// resolution would have failed — and that difference is exactly what `config show`
277/// reports and a user relies on.
278struct Handshake<'a>(&'a BTreeMap<String, String>);
279
280impl SecretResolver for Handshake<'_> {
281    fn get(&self, var: &str) -> Option<SecretString> {
282        self.0
283            .get(var)
284            .map(|value| SecretString::from(value.clone()))
285    }
286}
287
288/// One method call against the built source (§4).
289async fn dispatch(
290    source: &dyn TaskSource,
291    method: &str,
292    params: Value,
293) -> Result<Value, SourceError> {
294    match method {
295        "health" => encode(source.health().await?),
296        "get_task" => {
297            let params: IdParams = decode(method, params)?;
298            encode(json!({ "task": source.get_task(&params.id).await? }))
299        }
300        "get_project" => {
301            let params: IdParams = decode(method, params)?;
302            encode(json!({ "project": source.get_project(&params.id).await? }))
303        }
304        "query_tasks" => {
305            let params: TaskQueryParams = decode(method, params)?;
306            encode(source.query_tasks(&params.query, &params.page).await?)
307        }
308        "query_projects" => {
309            let params: ProjectQueryParams = decode(method, params)?;
310            encode(source.query_projects(&params.query, &params.page).await?)
311        }
312        "labels" => {
313            let params: LabelParams = decode(method, params)?;
314            encode(source.labels(&params.page).await?)
315        }
316        "task_dependencies" => {
317            let params: DependencyParams = decode(method, params)?;
318            encode(
319                source
320                    .task_dependencies(&params.id, params.direction, &params.page)
321                    .await?,
322            )
323        }
324        "project_dependencies" => {
325            let params: DependencyParams = decode(method, params)?;
326            encode(
327                source
328                    .project_dependencies(&params.id, params.direction, &params.page)
329                    .await?,
330            )
331        }
332        "write_task" => {
333            let params: TaskWriteParams = decode(method, params)?;
334            encode(json!({ "id": source.write_task(&params.write).await? }))
335        }
336        "write_project" => {
337            let params: ProjectWriteParams = decode(method, params)?;
338            encode(json!({ "id": source.write_project(&params.write).await? }))
339        }
340        "delete_task" => {
341            let params: DeleteParams = decode(method, params)?;
342            source.delete_task(&params.id).await?;
343            encode(json!({}))
344        }
345        "delete_project" => {
346            let params: DeleteParams = decode(method, params)?;
347            source.delete_project(&params.id).await?;
348            encode(json!({}))
349        }
350        other => Err(SourceError::Malformed {
351            message: format!("protocol version {PROTOCOL_VERSION} has no method called {other:?}"),
352        }),
353    }
354}
355
356/// One method's parameters, or the reason they could not be read.
357fn decode<T: for<'de> Deserialize<'de>>(method: &str, params: Value) -> Result<T, SourceError> {
358    serde_json::from_value(params).map_err(|error| SourceError::Malformed {
359        message: format!("the parameters of {method} are not the shape it takes: {error}"),
360    })
361}
362
363/// One method's result as the value the envelope carries.
364fn encode<T: serde::Serialize>(value: T) -> Result<Value, SourceError> {
365    serde_json::to_value(value).map_err(|error| SourceError::Malformed {
366        message: format!("this source returned data that will not serialize: {error}"),
367    })
368}