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