Skip to main content

onetaskgraph_core/subprocess/
source.rs

1//! The engine's half of the protocol: a [`TaskSource`] that is another process.
2//!
3//! Every method here is one line out and one line back. What it deliberately does *not*
4//! do is decide anything: a `forward-only` plugin is never asked for
5//! [`Direction::DependedOnBy`] because the layer above reads that off the capabilities
6//! this handshake returned and emulates the reverse scan itself, and a predicate a plugin
7//! declared unsupported is removed from the query before it ever reaches here. Putting
8//! either decision in this file would give the product a second compensation layer that
9//! only subprocess-hosted sources went through.
10
11use std::collections::BTreeMap;
12use std::num::NonZeroU64;
13use std::time::Duration;
14
15use async_trait::async_trait;
16use onetaskgraph_plugin_api::{
17    Capabilities, DependencyEdge, Direction, Health, ItemWrite, Label, NativeId, Page, PageRequest,
18    Project, ProjectQuery, SourceError, SourceName, Task, TaskQuery, TaskSource, WriteSupport,
19};
20use serde::Deserialize;
21use serde_json::{Value, json};
22
23use super::connection::{Connection, Peer};
24use super::wire::{
25    DependencyParams, EngineIdentity, IdParams, InitializeParams, InitializeResult, LabelParams,
26    PROTOCOL_VERSION, ProjectQueryParams, ProjectResult, ProjectWriteParams, Request,
27    TaskQueryParams, TaskResult, TaskWriteParams, WriteResult,
28};
29
30/// The id the handshake is sent under. §3 makes it the first request on a connection, so
31/// nothing else can have been sent under it, and an answer addressed elsewhere is a
32/// violation rather than an ordering the engine could accommodate.
33const HANDSHAKE_ID: &str = "0";
34
35/// A positive per-request deadline, measured in milliseconds at the configuration edge.
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub struct RequestDeadline(NonZeroU64);
38
39impl RequestDeadline {
40    /// The protocol's default deadline.
41    pub const DEFAULT: Self = Self(NonZeroU64::new(30_000).expect("non-zero default"));
42
43    /// Validate a millisecond value from a configuration or another public boundary.
44    #[must_use]
45    pub const fn from_millis(milliseconds: NonZeroU64) -> Self {
46        Self(milliseconds)
47    }
48
49    /// The positive millisecond count used by configuration and diagnostics.
50    #[must_use]
51    pub const fn milliseconds(self) -> NonZeroU64 {
52        self.0
53    }
54
55    fn duration(self) -> Duration {
56        Duration::from_millis(self.0.get())
57    }
58}
59
60/// A source served by a spawned program speaking `docs/plugin-protocol.md`.
61pub struct SubprocessSource {
62    /// What the plugin called itself in the handshake.
63    ///
64    /// Leaked once per connection because [`TaskSource::kind`] returns `&'static str` for
65    /// the compiled-in plugins, whose kinds really are static, and a subprocess-hosted
66    /// plugin's kind is not known until it answers. One small allocation per configured
67    /// source, for the life of a process that was going to hold that source anyway, is
68    /// the cheapest way to keep the trait honest for both.
69    kind: &'static str,
70    /// Read once at the handshake; §3 says the engine does not ask again.
71    capabilities: Capabilities,
72    /// Whether the plugin said it can be written through, read at the same handshake.
73    ///
74    /// A plugin that said nothing is read as read-only, which is what §3.3 makes an
75    /// absent member mean and what every version-1 plugin written before there was a
76    /// write side is.
77    writes: WriteSupport,
78    /// The live process.
79    connection: Connection,
80}
81
82impl std::fmt::Debug for SubprocessSource {
83    /// Named without its connection, which holds a live child and a credential the
84    /// handshake forwarded — neither belongs in a diagnostic.
85    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
86        f.debug_struct("SubprocessSource")
87            .field("kind", &self.kind)
88            .finish_non_exhaustive()
89    }
90}
91
92impl SubprocessSource {
93    /// Spawn `program`, complete the handshake, and adopt the connection.
94    ///
95    /// # Errors
96    ///
97    /// Returns [`SourceError::Unavailable`] when the program cannot be run or stops
98    /// answering, the plugin's own error when it refuses the handshake, and
99    /// [`SourceError::Config`] when the two sides do not speak the same protocol version
100    /// — refused by name, never guessed at (§6.1).
101    pub fn connect(
102        program: &str,
103        args: &[String],
104        name: &SourceName,
105        config: &Value,
106        secrets: BTreeMap<String, String>,
107    ) -> Result<Self, SourceError> {
108        Self::connect_with_deadline(
109            program,
110            args,
111            name,
112            config,
113            secrets,
114            RequestDeadline::DEFAULT,
115        )
116    }
117
118    /// Spawn a plugin with a deadline applying independently to every exchange.
119    pub fn connect_with_deadline(
120        program: &str,
121        args: &[String],
122        name: &SourceName,
123        config: &Value,
124        secrets: BTreeMap<String, String>,
125        deadline: RequestDeadline,
126    ) -> Result<Self, SourceError> {
127        Self::adopt(
128            Peer::spawn(program, args, deadline.duration())?,
129            name,
130            config,
131            secrets,
132        )
133    }
134
135    /// Connect to a plugin that is already running, over streams somebody else owns.
136    ///
137    /// The handshake, the framing and every refusal are the same as [`connect`]'s, because
138    /// they are the protocol's rather than the process's. What this constructor adds is
139    /// the ability to hold the *other* end: it is how the engine's own tests drive this
140    /// half against [`serve`](super::serve) over a real pipe, including the answers a
141    /// well-behaved program would never give.
142    ///
143    /// # Errors
144    ///
145    /// Returns what [`connect`](Self::connect) returns, minus the failures that belong to
146    /// spawning a program.
147    ///
148    /// [`connect`]: Self::connect
149    pub fn over(
150        to_plugin: impl std::io::Write + Send + 'static,
151        from_plugin: impl std::io::Read + Send + 'static,
152        name: &SourceName,
153        config: &Value,
154        secrets: BTreeMap<String, String>,
155    ) -> Result<Self, SourceError> {
156        Self::over_with_request_deadline(
157            to_plugin,
158            from_plugin,
159            name,
160            config,
161            secrets,
162            RequestDeadline::DEFAULT,
163        )
164    }
165
166    /// Connect over existing streams with a deadline for requests after initialization.
167    ///
168    /// Unlike [`connect_with_deadline`](Self::connect_with_deadline), this engine does
169    /// not own a process it can interrupt while the synchronous handshake is blocked.
170    /// The supplied deadline therefore begins only after initialization succeeds.
171    pub fn over_with_request_deadline(
172        to_plugin: impl std::io::Write + Send + 'static,
173        from_plugin: impl std::io::Read + Send + 'static,
174        name: &SourceName,
175        config: &Value,
176        secrets: BTreeMap<String, String>,
177        deadline: RequestDeadline,
178    ) -> Result<Self, SourceError> {
179        Self::adopt(
180            Peer::over(to_plugin, from_plugin, deadline.duration()),
181            name,
182            config,
183            secrets,
184        )
185    }
186
187    /// Shake hands with `peer` and take the connection over.
188    fn adopt(
189        mut peer: Peer,
190        name: &SourceName,
191        config: &Value,
192        secrets: BTreeMap<String, String>,
193    ) -> Result<Self, SourceError> {
194        let result = Self::handshake(&mut peer, name, config, secrets);
195        let InitializeResult {
196            protocol_version,
197            kind,
198            capabilities,
199            writes,
200        } = match result {
201            Ok(result) => result,
202            Err(error) => return Err(with_diagnostics(error, &mut peer)),
203        };
204        let kind = kind.into_string();
205        if protocol_version != Some(PROTOCOL_VERSION) {
206            return Err(SourceError::Config {
207                message: match protocol_version {
208                    Some(spoken) => format!(
209                        "the {kind:?} plugin was asked for protocol version \
210                         {PROTOCOL_VERSION} and answered in version {spoken}; the two are \
211                         incompatible and this engine does not guess between them"
212                    ),
213                    None => format!(
214                        "the {kind:?} plugin did not say which protocol version it \
215                         answered in; this engine speaks version {PROTOCOL_VERSION} and \
216                         does not guess"
217                    ),
218                },
219            });
220        }
221        Ok(Self {
222            kind: String::leak(kind),
223            capabilities,
224            writes: writes.unwrap_or(WriteSupport::Unsupported),
225            connection: Connection::adopt(peer),
226        })
227    }
228
229    /// Send `initialize` and read what came back (§3).
230    fn handshake(
231        peer: &mut Peer,
232        name: &SourceName,
233        config: &Value,
234        secrets: BTreeMap<String, String>,
235    ) -> Result<InitializeResult, SourceError> {
236        let params = InitializeParams {
237            protocol_version: PROTOCOL_VERSION,
238            engine: EngineIdentity {
239                name: "onetaskgraph".to_owned(),
240                version: env!("CARGO_PKG_VERSION").to_owned(),
241            },
242            source_name: name.as_str().to_owned(),
243            config: config.clone(),
244            secrets,
245        };
246        let request = Request {
247            id: HANDSHAKE_ID.to_owned(),
248            method: "initialize".to_owned(),
249            // Plain data throughout: a `BTreeMap<String, String>` and a `Value` the
250            // configuration layer already parsed.
251            params: serde_json::to_value(&params).expect("a handshake is plain data"),
252        };
253        let line = peer.exchange(
254            &serde_json::to_string(&request).expect("a handshake request is plain data"),
255        )?;
256        let response: super::wire::Response =
257            serde_json::from_str(&line).map_err(|error| SourceError::Malformed {
258                message: format!(
259                    "the plugin's handshake answer is not a response envelope: {error}"
260                ),
261            })?;
262        // §6.3: an envelope addressed to an id this side never sent is a violation, and it
263        // is one here for the same reason it is later — a plugin whose first line answers
264        // something else has not answered the handshake, and reading it as one would build
265        // a source out of a message that was about something different.
266        if response.id != HANDSHAKE_ID {
267            return Err(SourceError::Malformed {
268                message: format!(
269                    "the plugin answered the handshake with an envelope addressed to {:?} \
270                     rather than to {HANDSHAKE_ID:?}",
271                    response.id
272                ),
273            });
274        }
275        let outcome = response.outcome().ok_or_else(|| SourceError::Malformed {
276            message: "the plugin's handshake answer carried both a result and an error, or \
277                      neither"
278                .to_owned(),
279        })?;
280        let result = outcome?;
281        serde_json::from_value(result).map_err(|error| SourceError::Malformed {
282            message: format!("the plugin's handshake answer is not an initialize result: {error}"),
283        })
284    }
285
286    /// One call, with its result parsed into the shape the method promises.
287    async fn ask<T: for<'de> Deserialize<'de>>(
288        &self,
289        method: &str,
290        params: Value,
291    ) -> Result<T, SourceError> {
292        let result = self.connection.call(method, params).await?;
293        serde_json::from_value(result).map_err(|error| SourceError::Malformed {
294            message: format!(
295                "the plugin's answer to {method} is not the shape it promises: {error}"
296            ),
297        })
298    }
299}
300
301/// Append whatever the plugin said on standard error to a handshake failure.
302///
303/// A plugin that refuses the handshake and exits has usually said why there and nowhere
304/// else, and a bare "could not read the plugin's answer" would throw that away.
305fn with_diagnostics(error: SourceError, peer: &mut Peer) -> SourceError {
306    let said = peer.said();
307    if said.is_empty() {
308        return error;
309    }
310    let message = format!("{error}; the plugin wrote: {said}");
311    match error {
312        SourceError::RateLimited { .. } => error,
313        SourceError::Config { .. } => SourceError::Config { message },
314        SourceError::Auth { .. } => SourceError::Auth { message },
315        SourceError::Refused { .. } => SourceError::Refused { message },
316        SourceError::Malformed { .. } => SourceError::Malformed { message },
317        SourceError::Unavailable { .. } => SourceError::Unavailable { message },
318    }
319}
320
321#[async_trait]
322impl TaskSource for SubprocessSource {
323    fn kind(&self) -> &'static str {
324        self.kind
325    }
326
327    fn capabilities(&self) -> Capabilities {
328        self.capabilities.clone()
329    }
330
331    async fn health(&self) -> Result<Health, SourceError> {
332        self.ask("health", json!({})).await
333    }
334
335    async fn get_task(&self, id: &NativeId) -> Result<Option<Task>, SourceError> {
336        let result: TaskResult = self
337            .ask("get_task", params(&IdParams { id: id.clone() }))
338            .await?;
339        Ok(result.task)
340    }
341
342    async fn get_project(&self, id: &NativeId) -> Result<Option<Project>, SourceError> {
343        let result: ProjectResult = self
344            .ask("get_project", params(&IdParams { id: id.clone() }))
345            .await?;
346        Ok(result.project)
347    }
348
349    async fn query_tasks(
350        &self,
351        query: &TaskQuery,
352        page: &PageRequest,
353    ) -> Result<Page<Task>, SourceError> {
354        self.ask(
355            "query_tasks",
356            params(&TaskQueryParams {
357                query: query.clone(),
358                page: page.clone(),
359            }),
360        )
361        .await
362    }
363
364    async fn query_projects(
365        &self,
366        query: &ProjectQuery,
367        page: &PageRequest,
368    ) -> Result<Page<Project>, SourceError> {
369        self.ask(
370            "query_projects",
371            params(&ProjectQueryParams {
372                query: query.clone(),
373                page: page.clone(),
374            }),
375        )
376        .await
377    }
378
379    async fn labels(&self, page: &PageRequest) -> Result<Page<Label>, SourceError> {
380        self.ask("labels", params(&LabelParams { page: page.clone() }))
381            .await
382    }
383
384    async fn task_dependencies(
385        &self,
386        id: &NativeId,
387        direction: Direction,
388        page: &PageRequest,
389    ) -> Result<Page<DependencyEdge>, SourceError> {
390        self.ask(
391            "task_dependencies",
392            params(&DependencyParams {
393                id: id.clone(),
394                direction,
395                page: page.clone(),
396            }),
397        )
398        .await
399    }
400
401    async fn project_dependencies(
402        &self,
403        id: &NativeId,
404        direction: Direction,
405        page: &PageRequest,
406    ) -> Result<Page<DependencyEdge>, SourceError> {
407        self.ask(
408            "project_dependencies",
409            params(&DependencyParams {
410                id: id.clone(),
411                direction,
412                page: page.clone(),
413            }),
414        )
415        .await
416    }
417
418    fn writes(&self) -> WriteSupport {
419        self.writes
420    }
421
422    async fn write_task(&self, write: &ItemWrite<Task>) -> Result<NativeId, SourceError> {
423        let result: WriteResult = self
424            .ask(
425                "write_task",
426                params(&TaskWriteParams {
427                    write: write.clone(),
428                }),
429            )
430            .await?;
431        Ok(result.id)
432    }
433
434    async fn write_project(&self, write: &ItemWrite<Project>) -> Result<NativeId, SourceError> {
435        let result: WriteResult = self
436            .ask(
437                "write_project",
438                params(&ProjectWriteParams {
439                    write: write.clone(),
440                }),
441            )
442            .await?;
443        Ok(result.id)
444    }
445}
446
447/// One method's parameters as the object the envelope carries.
448///
449/// Every parameter type in `wire` is built from contract types that all serialize, so
450/// this cannot fail for a reason a caller could act on.
451fn params<T: serde::Serialize>(value: &T) -> Value {
452    serde_json::to_value(value).expect("method parameters are plain data")
453}