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