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::{
18    Page, Project, SecretResolver, SourceError, SourceName, Status, StatusCategory, Task,
19    TaskSource,
20};
21use secrecy::SecretString;
22use serde::Deserialize;
23use serde_json::{Value, json};
24
25use super::connection::{Line, MAX_LINE, read_line};
26use super::wire::{
27    AddCommentParams, CommentsParams, DeleteCommentParams, DeleteParams, DeliveredByParams,
28    DependencyParams, DocumentDir, DocumentQueryParams, DocumentWriteParams, EditCommentParams,
29    HandshakePluginKind, IdParams, InitializeParams, InitializeResult, LabelParams, MetadataParams,
30    PROTOCOL_VERSION, ProjectQueryParams, ProjectWriteParams, Request, Response, StatusParams,
31    TaskQueryParams, TaskWriteParams, after_the_first_vocabulary, knows_every_category, vocabulary,
32};
33use crate::config::rebased;
34use crate::registry::PluginKind;
35
36/// What this reference host needs in the `config` the handshake hands it.
37///
38/// The protocol says `config` is "this source's `config:` block, verbatim" and says
39/// nothing about its contents, because they are the plugin's own business. This host's
40/// business is to run one of *this build's* registered plugins, so its settings name
41/// which one and hand over that plugin's block untouched.
42#[derive(Debug, Clone, Deserialize)]
43struct HostedSettings {
44    /// The registered plugin kind to build.
45    kind: PluginKind,
46    /// That plugin's own `config:` block.
47    #[serde(default)]
48    config: Value,
49}
50
51/// The source one connection hosts, and what the engine said it can be told.
52struct Hosted {
53    /// The built source.
54    source: Box<dyn TaskSource>,
55    /// Whether the engine's `initialize` listed every status category this build knows
56    /// (§3.5). An engine that did not is told a category added after the first vocabulary
57    /// as `unknown`, keeping its name.
58    engine_knows_every_category: bool,
59}
60
61/// Serve one connection until the engine closes its input.
62///
63/// # Errors
64///
65/// Returns the underlying [`std::io::Error`] when this process can no longer read its
66/// input or write its output. Everything else — an unusable configuration, a request this
67/// version has no method for, a line that is not JSON — is answered on the wire or
68/// reported on standard error, because a plugin that exits on a bad line takes every
69/// other in-flight request with it (§6.3).
70pub async fn serve(input: impl BufRead, output: impl Write) -> std::io::Result<()> {
71    serve_kind(input, output, None).await
72}
73
74/// Serve one connection as the registered plugin `kind`.
75///
76/// Unlike [`serve`], the initialize request's `config` is handed directly to that plugin;
77/// the process command has already selected the kind it hosts.
78pub async fn serve_plugin(
79    input: impl BufRead,
80    output: impl Write,
81    kind: PluginKind,
82) -> std::io::Result<()> {
83    serve_kind(input, output, Some(kind)).await
84}
85
86async fn serve_kind(
87    mut input: impl BufRead,
88    mut output: impl Write,
89    kind: Option<PluginKind>,
90) -> std::io::Result<()> {
91    let mut source: Option<Hosted> = None;
92    loop {
93        let line = match read_line(&mut input) {
94            Line::Read(line) => line,
95            Line::Ended => return Ok(()),
96            Line::Failed(error) => return Err(error),
97            // Nothing after an unterminated line can be framed — the rest of it would be
98            // read as further requests it is not — so this side says why and stops rather
99            // than answering questions nobody asked. The engine sees the closed stream.
100            Line::TooLong => {
101                eprintln!(
102                    "onetaskgraph-source: a request ran past {MAX_LINE} bytes without \
103                     ending its line; closing the connection"
104                );
105                return Ok(());
106            }
107        };
108        if line.trim().is_empty() {
109            continue;
110        }
111        let Some(id) = addressed(&line) else {
112            eprintln!("onetaskgraph-source: ignoring a line with no request id: {line}");
113            continue;
114        };
115        let response = match serde_json::from_str::<Request>(&line) {
116            Ok(request) => answer(&mut source, request, kind).await,
117            Err(error) => Response::failed(
118                id,
119                SourceError::Malformed {
120                    message: format!("that is not a request envelope: {error}"),
121                },
122            ),
123        };
124        let finished = ended_the_connection(&response);
125        writeln!(
126            output,
127            "{}",
128            // A response is built from contract types that all serialize.
129            serde_json::to_string(&response).expect("a response is plain data")
130        )?;
131        output.flush()?;
132        if finished {
133            return Ok(());
134        }
135    }
136}
137
138/// The `id` a line is addressed with, if it has one at all.
139///
140/// Read from the raw JSON rather than from a parsed [`Request`] because §6.3 turns on
141/// exactly this difference: a request this side cannot otherwise understand is *answered*
142/// when an id can be associated with it, and only reported on standard error when one
143/// cannot.
144fn addressed(line: &str) -> Option<String> {
145    serde_json::from_str::<Value>(line)
146        .ok()?
147        .get("id")?
148        .as_str()
149        .map(str::to_owned)
150}
151
152/// Whether this response is a refusal §6.2 says the plugin exits after.
153fn ended_the_connection(response: &Response) -> bool {
154    matches!(
155        response.error.as_ref(),
156        Some(SourceError::Config { message }) if message.starts_with(VERSION_REFUSAL)
157    )
158}
159
160/// The opening of the one message §6.2 spells out, so the refusal and the exit that
161/// follows it cannot drift apart.
162const VERSION_REFUSAL: &str = "protocol version ";
163
164/// Answer one well-formed request.
165async fn answer(
166    source: &mut Option<Hosted>,
167    request: Request,
168    kind: Option<PluginKind>,
169) -> Response {
170    let Request { id, method, params } = request;
171    if method == "initialize" {
172        return match source {
173            Some(_) => Response::failed(
174                id,
175                SourceError::Malformed {
176                    message: "this connection was already initialized".to_owned(),
177                },
178            ),
179            None => initialize(source, id, params, kind).await,
180        };
181    }
182    let Some(built) = source.as_ref() else {
183        return Response::failed(
184            id,
185            SourceError::Malformed {
186                message: format!("{method} arrived before the handshake"),
187            },
188        );
189    };
190    match dispatch(
191        built.source.as_ref(),
192        built.engine_knows_every_category,
193        &method,
194        params,
195    )
196    .await
197    {
198        Ok(result) => Response::ok(id, result),
199        Err(error) => Response::failed(id, error),
200    }
201}
202
203/// The handshake (§3), including the version refusal §6.2 spells out.
204async fn initialize(
205    source: &mut Option<Hosted>,
206    id: String,
207    params: Value,
208    kind: Option<PluginKind>,
209) -> Response {
210    let params: InitializeParams = match serde_json::from_value(params) {
211        Ok(params) => params,
212        Err(error) => {
213            return Response::failed(
214                id,
215                SourceError::Config {
216                    message: format!("that is not an initialize request: {error}"),
217                },
218            );
219        }
220    };
221    if params.protocol_version != PROTOCOL_VERSION {
222        return Response::failed(
223            id,
224            SourceError::Config {
225                message: format!(
226                    "{VERSION_REFUSAL}{} is not supported by this plugin; it speaks \
227                     version {PROTOCOL_VERSION}",
228                    params.protocol_version
229                ),
230            },
231        );
232    }
233    match build(&params, kind) {
234        Ok(built) => {
235            let kind = match HandshakePluginKind::new(built.kind()) {
236                Ok(kind) => kind,
237                Err(error) => {
238                    return Response::failed(
239                        id,
240                        SourceError::Malformed {
241                            message: format!("the hosted plugin reported an invalid kind: {error}"),
242                        },
243                    );
244                }
245            };
246            let result = InitializeResult {
247                protocol_version: Some(PROTOCOL_VERSION),
248                kind,
249                capabilities: built.capabilities(),
250                writes: Some(built.writes()),
251                // Asked once, here, for the same reason `writes` is: the engine is then
252                // never sent a method this plugin would only have to decline.
253                meters: matches!(built.metering().await, Ok(Some(_))),
254                statuses: Some(vocabulary()),
255                // This host serves this build's own plugins, whose every one implements both
256                // narrow writes or refuses one by name — which is an answer, not a method the
257                // engine should not have sent.
258                task_updates: true,
259                // On the same terms: every plugin of this build sets one metadata key or
260                // refuses to in the contract's own words.
261                metadata_updates: true,
262            };
263            *source = Some(Hosted {
264                source: built,
265                engine_knows_every_category: knows_every_category(params.statuses.as_deref()),
266            });
267            Response::ok(
268                id,
269                serde_json::to_value(&result).expect("a result is plain data"),
270            )
271        }
272        Err(error) => Response::failed(id, error),
273    }
274}
275
276/// Build the registered plugin these settings name.
277fn build(
278    params: &InitializeParams,
279    selected: Option<PluginKind>,
280) -> Result<Box<dyn TaskSource>, SourceError> {
281    let (kind, config) = match selected {
282        Some(kind) => (kind, &params.config),
283        None => {
284            let settings: HostedSettings = serde_json::from_value(params.config.clone()).map_err(
285                |error| SourceError::Config {
286                    message: format!(
287                        "this host serves a plugin of this build, and its settings must name one \
288                         as {{\"kind\": …, \"config\": …}}: {error}"
289                    ),
290                },
291            )?;
292            return build_plugin(params, settings.kind, &settings.config);
293        }
294    };
295    build_plugin(params, kind, config)
296}
297
298fn build_plugin(
299    params: &InitializeParams,
300    kind: PluginKind,
301    config: &Value,
302) -> Result<Box<dyn TaskSource>, SourceError> {
303    let name = SourceName::new(params.source_name.clone())?;
304    let plugin = kind.plugin();
305    let config = measured_from_document(
306        params.document_dir.as_ref(),
307        plugin.document_relative_paths(),
308        config,
309    );
310    plugin.build(&name, &config, &Handshake(&params.secrets))
311}
312
313/// `config` with every relative path at one of `fields` measured from `document_dir`.
314///
315/// This is the in-process rule of `crate::config`'s `relative` module carried across the
316/// seam (§3): the engine keeps a `subprocess` source's settings opaque and rebases
317/// nothing inside them, so the hosted plugin's own declaration decides which fields are
318/// paths, and a plugin declaring none has nothing resolved. With no `document_dir` the
319/// block is handed over untouched, and a relative path goes on meaning the working
320/// directory — which is what the engine sends when the block came from the environment
321/// or a flag rather than from a document.
322fn measured_from_document(
323    document_dir: Option<&DocumentDir>,
324    fields: &[&str],
325    config: &Value,
326) -> Value {
327    let mut config = config.clone();
328    // Absolute by construction: a relative one was refused as the handshake was read.
329    let Some(directory) = document_dir.map(DocumentDir::as_path) else {
330        return config;
331    };
332    for field in fields {
333        let Some(value) = field
334            .split('.')
335            .try_fold(&mut config, |cursor, segment| cursor.get_mut(segment))
336        else {
337            continue;
338        };
339        let Some(rebased) = value.as_str().and_then(|raw| rebased(directory, raw)) else {
340            continue;
341        };
342        // Both halves arrived as JSON strings, so the path they join into is one too.
343        let rebased = rebased
344            .into_os_string()
345            .into_string()
346            .expect("a path joined from two strings is a string");
347        *value = Value::String(rebased);
348    }
349    config
350}
351
352/// The credentials the handshake forwarded, and nothing else.
353///
354/// §3.1 is the whole of this type: a plugin must not read credentials from its own
355/// process environment, because doing so makes it work on a host where the engine's own
356/// resolution would have failed — and that difference is exactly what `config show`
357/// reports and a user relies on.
358struct Handshake<'a>(&'a BTreeMap<String, String>);
359
360impl SecretResolver for Handshake<'_> {
361    fn get(&self, var: &str) -> Option<SecretString> {
362        self.0
363            .get(var)
364            .map(|value| SecretString::from(value.clone()))
365    }
366}
367
368/// A status as an engine that knows `known` categories may be told it (§3.5).
369fn told(status: Status, known: bool) -> Status {
370    if known || !after_the_first_vocabulary(status.category) {
371        return status;
372    }
373    Status {
374        category: StatusCategory::Unknown,
375        name: status.name,
376    }
377}
378
379fn told_task(task: Task, known: bool) -> Task {
380    Task {
381        status: told(task.status.clone(), known),
382        ..task
383    }
384}
385
386fn told_project(project: Project, known: bool) -> Project {
387    Project {
388        status: told(project.status.clone(), known),
389        ..project
390    }
391}
392
393/// One method call against the built source (§4).
394///
395/// `known` is whether the engine listed every status category this build knows; every
396/// status this answers with passes through [`told`] on its way out.
397async fn dispatch(
398    source: &dyn TaskSource,
399    known: bool,
400    method: &str,
401    params: Value,
402) -> Result<Value, SourceError> {
403    match method {
404        "health" => encode(source.health().await?),
405        "get_task" => {
406            let params: IdParams = decode(method, params)?;
407            let task = source.get_task(&params.id).await?;
408            encode(json!({ "task": task.map(|task| told_task(task, known)) }))
409        }
410        "get_project" => {
411            let params: IdParams = decode(method, params)?;
412            let project = source.get_project(&params.id).await?;
413            encode(json!({ "project": project.map(|project| told_project(project, known)) }))
414        }
415        "query_tasks" => {
416            let params: TaskQueryParams = decode(method, params)?;
417            let page = source.query_tasks(&params.query, &params.page).await?;
418            encode(Page {
419                items: page
420                    .items
421                    .into_iter()
422                    .map(|task| told_task(task, known))
423                    .collect(),
424                next: page.next,
425            })
426        }
427        "query_projects" => {
428            let params: ProjectQueryParams = decode(method, params)?;
429            let page = source.query_projects(&params.query, &params.page).await?;
430            encode(Page {
431                items: page
432                    .items
433                    .into_iter()
434                    .map(|project| told_project(project, known))
435                    .collect(),
436                next: page.next,
437            })
438        }
439        "set_task_status" => {
440            let params: StatusParams = decode(method, params)?;
441            let status = source.set_task_status(&params.id, params.category).await?;
442            encode(json!({ "status": status.map(|status| told(status, known)) }))
443        }
444        "set_task_metadata" => {
445            let params: MetadataParams = decode(method, params)?;
446            let task = source
447                .set_task_metadata(&params.id, &params.key, &params.value)
448                .await?;
449            encode(json!({ "task": task.map(|task| told_task(task, known)) }))
450        }
451        "set_project_metadata" => {
452            let params: MetadataParams = decode(method, params)?;
453            let project = source
454                .set_project_metadata(&params.id, &params.key, &params.value)
455                .await?;
456            encode(json!({ "project": project.map(|project| told_project(project, known)) }))
457        }
458        "set_document_metadata" => {
459            let params: MetadataParams = decode(method, params)?;
460            let document = source
461                .set_document_metadata(&params.id, &params.key, &params.value)
462                .await?;
463            encode(json!({ "document": document }))
464        }
465        "set_delivered_by" => {
466            let params: DeliveredByParams = decode(method, params)?;
467            let held = source
468                .set_delivered_by(&params.id, &params.delivered_by)
469                .await?;
470            encode(json!({ "delivered_by": held.map(|()| params.delivered_by) }))
471        }
472        "labels" => {
473            let params: LabelParams = decode(method, params)?;
474            encode(source.labels(&params.page).await?)
475        }
476        "task_dependencies" => {
477            let params: DependencyParams = decode(method, params)?;
478            encode(
479                source
480                    .task_dependencies(&params.id, params.direction, &params.page)
481                    .await?,
482            )
483        }
484        "project_dependencies" => {
485            let params: DependencyParams = decode(method, params)?;
486            encode(
487                source
488                    .project_dependencies(&params.id, params.direction, &params.page)
489                    .await?,
490            )
491        }
492        "write_task" => {
493            let params: TaskWriteParams = decode(method, params)?;
494            encode(json!({ "id": source.write_task(&params.write).await? }))
495        }
496        "write_project" => {
497            let params: ProjectWriteParams = decode(method, params)?;
498            encode(json!({ "id": source.write_project(&params.write).await? }))
499        }
500        "delete_task" => {
501            let params: DeleteParams = decode(method, params)?;
502            source.delete_task(&params.id).await?;
503            encode(json!({}))
504        }
505        "delete_project" => {
506            let params: DeleteParams = decode(method, params)?;
507            source.delete_project(&params.id).await?;
508            encode(json!({}))
509        }
510        "get_document" => {
511            let params: IdParams = decode(method, params)?;
512            encode(json!({ "document": source.get_document(&params.id).await? }))
513        }
514        "query_documents" => {
515            let params: DocumentQueryParams = decode(method, params)?;
516            encode(source.query_documents(&params.query, &params.page).await?)
517        }
518        "write_document" => {
519            let params: DocumentWriteParams = decode(method, params)?;
520            encode(json!({ "id": source.write_document(&params.write).await? }))
521        }
522        "delete_document" => {
523            let params: DeleteParams = decode(method, params)?;
524            source.delete_document(&params.id).await?;
525            encode(json!({}))
526        }
527        "task_comments" => {
528            let params: CommentsParams = decode(method, params)?;
529            encode(json!({ "page": source.task_comments(&params.task, &params.page).await? }))
530        }
531        "add_comment" => {
532            let params: AddCommentParams = decode(method, params)?;
533            encode(json!({ "comment": source.add_comment(&params.task, &params.comment).await? }))
534        }
535        "edit_comment" => {
536            let params: EditCommentParams = decode(method, params)?;
537            encode(json!({
538                "comment": source
539                    .edit_comment(&params.task, &params.comment, &params.body)
540                    .await?
541            }))
542        }
543        "delete_comment" => {
544            let params: DeleteCommentParams = decode(method, params)?;
545            encode(json!({
546                "deleted": source.delete_comment(&params.task, &params.comment).await?
547            }))
548        }
549        "metering" => encode(json!({ "metering": source.metering().await? })),
550        other => Err(SourceError::Malformed {
551            message: format!("protocol version {PROTOCOL_VERSION} has no method called {other:?}"),
552        }),
553    }
554}
555
556/// One method's parameters, or the reason they could not be read.
557fn decode<T: for<'de> Deserialize<'de>>(method: &str, params: Value) -> Result<T, SourceError> {
558    serde_json::from_value(params).map_err(|error| SourceError::Malformed {
559        message: format!("the parameters of {method} are not the shape it takes: {error}"),
560    })
561}
562
563/// One method's result as the value the envelope carries.
564fn encode<T: serde::Serialize>(value: T) -> Result<Value, SourceError> {
565    serde_json::to_value(value).map_err(|error| SourceError::Malformed {
566        message: format!("this source returned data that will not serialize: {error}"),
567    })
568}