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::path::Path;
14use std::time::Duration;
15
16use async_trait::async_trait;
17use onetaskgraph_plugin_api::{
18    Capabilities, Comment, CommentBody, DependencyEdge, Direction, Document, DocumentQuery, Health,
19    ItemWrite, Label, MetadataKey, MetadataRecord, Metering, NativeId, NewComment, Page,
20    PageRequest, Project, ProjectQuery, SourceError, SourceName, Status, StatusCategory, Task,
21    TaskQuery, TaskRef, TaskSource, WriteSupport, unwritable_metadata,
22};
23use serde::Deserialize;
24use serde_json::{Value, json};
25
26use super::connection::{Connection, Peer};
27use super::wire::{
28    AddCommentParams, CommentResult, CommentsParams, CommentsResult, DeleteCommentParams,
29    DeleteParams, DeletedCommentResult, DeliveredByParams, DeliveredByResult, DependencyParams,
30    DocumentDir, DocumentQueryParams, DocumentResult, DocumentWriteParams, EditCommentParams,
31    EngineIdentity, IdParams, InitializeParams, InitializeResult, LabelParams, MetadataParams,
32    MeteringResult, PROTOCOL_VERSION, ProjectQueryParams, ProjectResult, ProjectWriteParams,
33    Request, StatusParams, StatusResult, TaskQueryParams, TaskResult, TaskWriteParams, WriteResult,
34    after_the_first_vocabulary, knows_every_category, spelled, vocabulary,
35};
36
37/// The id the handshake is sent under. §3 makes it the first request on a connection, so
38/// nothing else can have been sent under it, and an answer addressed elsewhere is a
39/// violation rather than an ordering the engine could accommodate.
40const HANDSHAKE_ID: &str = "0";
41
42/// A positive per-request deadline, measured in milliseconds at the configuration edge.
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub struct RequestDeadline(NonZeroU64);
45
46impl RequestDeadline {
47    /// The protocol's default deadline.
48    pub const DEFAULT: Self = Self(NonZeroU64::new(30_000).expect("non-zero default"));
49
50    /// Validate a millisecond value from a configuration or another public boundary.
51    #[must_use]
52    pub const fn from_millis(milliseconds: NonZeroU64) -> Self {
53        Self(milliseconds)
54    }
55
56    /// The positive millisecond count used by configuration and diagnostics.
57    #[must_use]
58    pub const fn milliseconds(self) -> NonZeroU64 {
59        self.0
60    }
61
62    fn duration(self) -> Duration {
63        Duration::from_millis(self.0.get())
64    }
65}
66
67/// A source served by a spawned program speaking `docs/plugin-protocol.md`.
68pub struct SubprocessSource {
69    /// What the plugin called itself in the handshake.
70    ///
71    /// Leaked once per connection because [`TaskSource::kind`] returns `&'static str` for
72    /// the compiled-in plugins, whose kinds really are static, and a subprocess-hosted
73    /// plugin's kind is not known until it answers. One small allocation per configured
74    /// source, for the life of a process that was going to hold that source anyway, is
75    /// the cheapest way to keep the trait honest for both.
76    kind: &'static str,
77    /// Read once at the handshake; §3 says the engine does not ask again.
78    capabilities: Capabilities,
79    /// Whether the plugin said it can be written through, read at the same handshake.
80    ///
81    /// A plugin that said nothing is read as read-only, which is what §3.3 makes an
82    /// absent member mean and what every version-1 plugin written before there was a
83    /// write side is.
84    writes: WriteSupport,
85    /// Whether the plugin said it answers `metering`, read at the same handshake.
86    ///
87    /// A plugin that said nothing is never sent the method and is reported as not metering,
88    /// which is what §3.4 makes an absent member mean.
89    meters: bool,
90    /// Whether the plugin's handshake listed every status category this build knows (§3.5).
91    ///
92    /// A plugin that listed none was written against the first vocabulary, and is never
93    /// handed a category added after it: not in a query, not in a write.
94    knows_every_category: bool,
95    /// Whether the plugin said it answers the two narrow task writes and holds a task's two
96    /// lists (§3.6), read at the same handshake.
97    task_updates: bool,
98    /// Whether the plugin said it answers the three narrow metadata writes (§3.7), read at the
99    /// same handshake.
100    metadata_updates: bool,
101    /// The live process.
102    connection: Connection,
103}
104
105impl std::fmt::Debug for SubprocessSource {
106    /// Named without its connection, which holds a live child and a credential the
107    /// handshake forwarded — neither belongs in a diagnostic.
108    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
109        f.debug_struct("SubprocessSource")
110            .field("kind", &self.kind)
111            .finish_non_exhaustive()
112    }
113}
114
115impl SubprocessSource {
116    /// Spawn `program`, complete the handshake, and adopt the connection.
117    ///
118    /// # Errors
119    ///
120    /// Returns [`SourceError::Unavailable`] when the program cannot be run or stops
121    /// answering, the plugin's own error when it refuses the handshake, and
122    /// [`SourceError::Config`] when the two sides do not speak the same protocol version
123    /// — refused by name, never guessed at (§6.1).
124    pub fn connect(
125        program: &str,
126        args: &[String],
127        name: &SourceName,
128        config: &Value,
129        secrets: BTreeMap<String, String>,
130    ) -> Result<Self, SourceError> {
131        Self::connect_with_deadline(
132            program,
133            args,
134            name,
135            config,
136            secrets,
137            RequestDeadline::DEFAULT,
138        )
139    }
140
141    /// Spawn a plugin with a deadline applying independently to every exchange.
142    pub fn connect_with_deadline(
143        program: &str,
144        args: &[String],
145        name: &SourceName,
146        config: &Value,
147        secrets: BTreeMap<String, String>,
148        deadline: RequestDeadline,
149    ) -> Result<Self, SourceError> {
150        Self::connect_from_document(program, args, name, config, secrets, deadline, None)
151    }
152
153    /// Spawn a plugin, telling it which document's directory its settings are measured
154    /// from — `document_dir` in `docs/plugin-protocol.md` §3, sent only when there is one.
155    ///
156    /// # Errors
157    ///
158    /// What [`connect`](Self::connect) returns, and [`SourceError::Config`] when
159    /// `document_dir` is not an absolute directory whose name is valid UTF-8, and so
160    /// cannot be written into the handshake — refused rather than dropped, because dropping
161    /// it would silently measure the child's paths from its working directory instead.
162    pub fn connect_from_document(
163        program: &str,
164        args: &[String],
165        name: &SourceName,
166        config: &Value,
167        secrets: BTreeMap<String, String>,
168        deadline: RequestDeadline,
169        document_dir: Option<&Path>,
170    ) -> Result<Self, SourceError> {
171        let document_dir = document_dir
172            .map(|directory| {
173                DocumentDir::new(directory).map_err(|problem| SourceError::Config {
174                    message: format!(
175                        "source {name}: its settings are measured from the directory holding \
176                         the configuration document that set them, and {problem}; give the \
177                         settings absolute paths, or move the document under a directory \
178                         whose name is valid UTF-8"
179                    ),
180                })
181            })
182            .transpose()?;
183        Self::adopt(
184            Peer::spawn(program, args, deadline.duration())?,
185            name,
186            config,
187            secrets,
188            document_dir,
189        )
190    }
191
192    /// Connect to a plugin that is already running, over streams somebody else owns.
193    ///
194    /// The handshake, the framing and every refusal are the same as [`connect`]'s, because
195    /// they are the protocol's rather than the process's. What this constructor adds is
196    /// the ability to hold the *other* end: it is how the engine's own tests drive this
197    /// half against [`serve`](super::serve) over a real pipe, including the answers a
198    /// well-behaved program would never give.
199    ///
200    /// # Errors
201    ///
202    /// Returns what [`connect`](Self::connect) returns, minus the failures that belong to
203    /// spawning a program.
204    ///
205    /// [`connect`]: Self::connect
206    pub fn over(
207        to_plugin: impl std::io::Write + Send + 'static,
208        from_plugin: impl std::io::Read + Send + 'static,
209        name: &SourceName,
210        config: &Value,
211        secrets: BTreeMap<String, String>,
212    ) -> Result<Self, SourceError> {
213        Self::over_with_request_deadline(
214            to_plugin,
215            from_plugin,
216            name,
217            config,
218            secrets,
219            RequestDeadline::DEFAULT,
220        )
221    }
222
223    /// Connect over existing streams with a deadline for requests after initialization.
224    ///
225    /// Unlike [`connect_with_deadline`](Self::connect_with_deadline), this engine does
226    /// not own a process it can interrupt while the synchronous handshake is blocked.
227    /// The supplied deadline therefore begins only after initialization succeeds.
228    pub fn over_with_request_deadline(
229        to_plugin: impl std::io::Write + Send + 'static,
230        from_plugin: impl std::io::Read + Send + 'static,
231        name: &SourceName,
232        config: &Value,
233        secrets: BTreeMap<String, String>,
234        deadline: RequestDeadline,
235    ) -> Result<Self, SourceError> {
236        Self::adopt(
237            Peer::over(to_plugin, from_plugin, deadline.duration()),
238            name,
239            config,
240            secrets,
241            None,
242        )
243    }
244
245    /// Shake hands with `peer` and take the connection over.
246    fn adopt(
247        mut peer: Peer,
248        name: &SourceName,
249        config: &Value,
250        secrets: BTreeMap<String, String>,
251        document_dir: Option<DocumentDir>,
252    ) -> Result<Self, SourceError> {
253        let result = Self::handshake(&mut peer, name, config, secrets, document_dir);
254        let InitializeResult {
255            protocol_version,
256            kind,
257            capabilities,
258            writes,
259            meters,
260            statuses,
261            task_updates,
262            metadata_updates,
263        } = match result {
264            Ok(result) => result,
265            Err(error) => return Err(with_diagnostics(error, &mut peer)),
266        };
267        let kind = kind.into_string();
268        if protocol_version != Some(PROTOCOL_VERSION) {
269            return Err(SourceError::Config {
270                message: match protocol_version {
271                    Some(spoken) => format!(
272                        "the {kind:?} plugin was asked for protocol version \
273                         {PROTOCOL_VERSION} and answered in version {spoken}; the two are \
274                         incompatible and this engine does not guess between them"
275                    ),
276                    None => format!(
277                        "the {kind:?} plugin did not say which protocol version it \
278                         answered in; this engine speaks version {PROTOCOL_VERSION} and \
279                         does not guess"
280                    ),
281                },
282            });
283        }
284        Ok(Self {
285            kind: String::leak(kind),
286            capabilities,
287            writes: writes.unwrap_or(WriteSupport::Unsupported),
288            meters,
289            knows_every_category: knows_every_category(statuses.as_deref()),
290            task_updates,
291            metadata_updates,
292            connection: Connection::adopt(peer),
293        })
294    }
295
296    /// Send `initialize` and read what came back (§3).
297    fn handshake(
298        peer: &mut Peer,
299        name: &SourceName,
300        config: &Value,
301        secrets: BTreeMap<String, String>,
302        document_dir: Option<DocumentDir>,
303    ) -> Result<InitializeResult, SourceError> {
304        let params = InitializeParams {
305            protocol_version: PROTOCOL_VERSION,
306            engine: EngineIdentity {
307                name: "onetaskgraph".to_owned(),
308                version: env!("CARGO_PKG_VERSION").to_owned(),
309            },
310            source_name: name.as_str().to_owned(),
311            config: config.clone(),
312            secrets,
313            statuses: Some(vocabulary()),
314            document_dir,
315        };
316        let request = Request {
317            id: HANDSHAKE_ID.to_owned(),
318            method: "initialize".to_owned(),
319            // Plain data throughout: a `BTreeMap<String, String>` and a `Value` the
320            // configuration layer already parsed.
321            params: serde_json::to_value(&params).expect("a handshake is plain data"),
322        };
323        let line = peer.exchange(
324            &serde_json::to_string(&request).expect("a handshake request is plain data"),
325        )?;
326        let response: super::wire::Response =
327            serde_json::from_str(&line).map_err(|error| SourceError::Malformed {
328                message: format!(
329                    "the plugin's handshake answer is not a response envelope: {error}"
330                ),
331            })?;
332        // §6.3: an envelope addressed to an id this side never sent is a violation, and it
333        // is one here for the same reason it is later — a plugin whose first line answers
334        // something else has not answered the handshake, and reading it as one would build
335        // a source out of a message that was about something different.
336        if response.id != HANDSHAKE_ID {
337            return Err(SourceError::Malformed {
338                message: format!(
339                    "the plugin answered the handshake with an envelope addressed to {:?} \
340                     rather than to {HANDSHAKE_ID:?}",
341                    response.id
342                ),
343            });
344        }
345        let outcome = response.outcome().ok_or_else(|| SourceError::Malformed {
346            message: "the plugin's handshake answer carried both a result and an error, or \
347                      neither"
348                .to_owned(),
349        })?;
350        let result = outcome?;
351        serde_json::from_value(result).map_err(|error| SourceError::Malformed {
352            message: format!("the plugin's handshake answer is not an initialize result: {error}"),
353        })
354    }
355
356    /// The statuses a query may hand this plugin, or `None` when every one asked for is a
357    /// category it was not written against — which no row it holds can be in.
358    ///
359    /// Dropping those categories narrows nothing: a plugin that does not know a category
360    /// cannot report a row in it, so the rows the rest of the list matches are every row the
361    /// whole list matches.
362    fn statuses_for(&self, statuses: &[StatusCategory]) -> Option<Vec<StatusCategory>> {
363        if self.knows_every_category {
364            return Some(statuses.to_vec());
365        }
366        let known: Vec<StatusCategory> = statuses
367            .iter()
368            .copied()
369            .filter(|category| !after_the_first_vocabulary(*category))
370            .collect();
371        (known.len() == statuses.len() || !known.is_empty()).then_some(known)
372    }
373
374    /// Refuse a status this plugin's handshake says it was not written against, before it is
375    /// sent (§3.5).
376    fn knows(&self, category: StatusCategory) -> Result<(), SourceError> {
377        if self.knows_every_category || !after_the_first_vocabulary(category) {
378            return Ok(());
379        }
380        Err(SourceError::Refused {
381            message: format!(
382                "the {:?} plugin's handshake does not list the status category {}, so this \
383                 engine does not hand it one (docs/plugin-protocol.md §3.5); next: upgrade the \
384                 plugin to one whose handshake lists it, or use a category it knows",
385                self.kind,
386                spelled(category)
387            ),
388        })
389    }
390
391    /// Refuse what only a plugin declaring `task_updates` is handed, before it is sent (§3.6).
392    fn updates(&self, what: &str) -> Result<(), SourceError> {
393        if self.task_updates {
394            return Ok(());
395        }
396        Err(SourceError::Refused {
397            message: format!(
398                "the {:?} plugin's handshake does not say it answers the narrow task writes, so \
399                 this engine does not send it {what} (docs/plugin-protocol.md §3.6); next: \
400                 upgrade the plugin to one whose handshake sets task_updates",
401                self.kind
402            ),
403        })
404    }
405
406    /// Refuse a narrow metadata write of `record` to a plugin whose handshake did not declare
407    /// `metadata_updates`, before it is sent (§3.7), in the contract's own words.
408    fn metadata_updates(&self, record: MetadataRecord) -> Result<(), SourceError> {
409        if self.metadata_updates {
410            return Ok(());
411        }
412        Err(unwritable_metadata(self.kind, record))
413    }
414
415    /// Refuse a task write this plugin could only drop part of in silence.
416    fn writable_task(&self, task: &Task) -> Result<(), SourceError> {
417        self.knows(task.status.category)?;
418        if !task.delivers.is_empty() || !task.delivered_by.is_empty() {
419            self.updates("a task carrying delivers or delivered_by")?;
420        }
421        Ok(())
422    }
423
424    /// One call, with its result parsed into the shape the method promises.
425    async fn ask<T: for<'de> Deserialize<'de>>(
426        &self,
427        method: &str,
428        params: Value,
429    ) -> Result<T, SourceError> {
430        let result = self.connection.call(method, params).await?;
431        serde_json::from_value(result).map_err(|error| SourceError::Malformed {
432            message: format!(
433                "the plugin's answer to {method} is not the shape it promises: {error}"
434            ),
435        })
436    }
437}
438
439/// Append whatever the plugin said on standard error to a handshake failure.
440///
441/// A plugin that refuses the handshake and exits has usually said why there and nowhere
442/// else, and a bare "could not read the plugin's answer" would throw that away.
443fn with_diagnostics(error: SourceError, peer: &mut Peer) -> SourceError {
444    let said = peer.said();
445    if said.is_empty() {
446        return error;
447    }
448    let message = format!("{error}; the plugin wrote: {said}");
449    match error {
450        // The wait it asked for is preserved: what the plugin wrote is extra reason, not a
451        // replacement for the one piece of this refusal the engine acts on.
452        SourceError::RateLimited {
453            retry_after_seconds,
454            ..
455        } => SourceError::RateLimited {
456            retry_after_seconds,
457            message: Some(message),
458        },
459        SourceError::Config { .. } => SourceError::Config { message },
460        SourceError::Auth { .. } => SourceError::Auth { message },
461        SourceError::Refused { .. } => SourceError::Refused { message },
462        SourceError::Malformed { .. } => SourceError::Malformed { message },
463        SourceError::Unavailable { .. } => SourceError::Unavailable { message },
464    }
465}
466
467#[async_trait]
468impl TaskSource for SubprocessSource {
469    fn kind(&self) -> &'static str {
470        self.kind
471    }
472
473    fn capabilities(&self) -> Capabilities {
474        self.capabilities.clone()
475    }
476
477    async fn health(&self) -> Result<Health, SourceError> {
478        self.ask("health", json!({})).await
479    }
480
481    async fn get_task(&self, id: &NativeId) -> Result<Option<Task>, SourceError> {
482        let result: TaskResult = self
483            .ask("get_task", params(&IdParams { id: id.clone() }))
484            .await?;
485        Ok(result.task)
486    }
487
488    async fn get_project(&self, id: &NativeId) -> Result<Option<Project>, SourceError> {
489        let result: ProjectResult = self
490            .ask("get_project", params(&IdParams { id: id.clone() }))
491            .await?;
492        Ok(result.project)
493    }
494
495    async fn query_tasks(
496        &self,
497        query: &TaskQuery,
498        page: &PageRequest,
499    ) -> Result<Page<Task>, SourceError> {
500        let Some(statuses) = self.statuses_for(&query.statuses) else {
501            return Ok(Page::last(Vec::new()));
502        };
503        self.ask(
504            "query_tasks",
505            params(&TaskQueryParams {
506                query: TaskQuery {
507                    statuses,
508                    ..query.clone()
509                },
510                page: page.clone(),
511            }),
512        )
513        .await
514    }
515
516    async fn query_projects(
517        &self,
518        query: &ProjectQuery,
519        page: &PageRequest,
520    ) -> Result<Page<Project>, SourceError> {
521        let Some(statuses) = self.statuses_for(&query.statuses) else {
522            return Ok(Page::last(Vec::new()));
523        };
524        self.ask(
525            "query_projects",
526            params(&ProjectQueryParams {
527                query: ProjectQuery {
528                    statuses,
529                    ..query.clone()
530                },
531                page: page.clone(),
532            }),
533        )
534        .await
535    }
536
537    async fn labels(&self, page: &PageRequest) -> Result<Page<Label>, SourceError> {
538        self.ask("labels", params(&LabelParams { page: page.clone() }))
539            .await
540    }
541
542    async fn task_dependencies(
543        &self,
544        id: &NativeId,
545        direction: Direction,
546        page: &PageRequest,
547    ) -> Result<Page<DependencyEdge>, SourceError> {
548        self.ask(
549            "task_dependencies",
550            params(&DependencyParams {
551                id: id.clone(),
552                direction,
553                page: page.clone(),
554            }),
555        )
556        .await
557    }
558
559    async fn project_dependencies(
560        &self,
561        id: &NativeId,
562        direction: Direction,
563        page: &PageRequest,
564    ) -> Result<Page<DependencyEdge>, SourceError> {
565        self.ask(
566            "project_dependencies",
567            params(&DependencyParams {
568                id: id.clone(),
569                direction,
570                page: page.clone(),
571            }),
572        )
573        .await
574    }
575
576    fn writes(&self) -> WriteSupport {
577        self.writes
578    }
579
580    async fn write_task(&self, write: &ItemWrite<Task>) -> Result<NativeId, SourceError> {
581        self.writable_task(&write.item)?;
582        let result: WriteResult = self
583            .ask(
584                "write_task",
585                params(&TaskWriteParams {
586                    write: write.clone(),
587                }),
588            )
589            .await?;
590        Ok(result.id)
591    }
592
593    async fn write_project(&self, write: &ItemWrite<Project>) -> Result<NativeId, SourceError> {
594        self.knows(write.item.status.category)?;
595        let result: WriteResult = self
596            .ask(
597                "write_project",
598                params(&ProjectWriteParams {
599                    write: write.clone(),
600                }),
601            )
602            .await?;
603        Ok(result.id)
604    }
605
606    async fn set_task_status(
607        &self,
608        id: &NativeId,
609        category: StatusCategory,
610    ) -> Result<Option<Status>, SourceError> {
611        self.updates("set_task_status")?;
612        self.knows(category)?;
613        let result: StatusResult = self
614            .ask(
615                "set_task_status",
616                params(&StatusParams {
617                    id: id.clone(),
618                    category,
619                }),
620            )
621            .await?;
622        Ok(result.status)
623    }
624
625    async fn set_delivered_by(
626        &self,
627        id: &NativeId,
628        delivered_by: &[TaskRef],
629    ) -> Result<Option<()>, SourceError> {
630        self.updates("set_delivered_by")?;
631        let result: DeliveredByResult = self
632            .ask(
633                "set_delivered_by",
634                params(&DeliveredByParams {
635                    id: id.clone(),
636                    delivered_by: delivered_by.to_vec(),
637                }),
638            )
639            .await?;
640        Ok(result.delivered_by.map(|_| ()))
641    }
642
643    async fn set_task_metadata(
644        &self,
645        id: &NativeId,
646        key: &MetadataKey,
647        value: &Value,
648    ) -> Result<Option<Task>, SourceError> {
649        self.metadata_updates(MetadataRecord::Task)?;
650        let result: TaskResult = self
651            .ask("set_task_metadata", metadata_params(id, key, value))
652            .await?;
653        Ok(result.task)
654    }
655
656    async fn set_project_metadata(
657        &self,
658        id: &NativeId,
659        key: &MetadataKey,
660        value: &Value,
661    ) -> Result<Option<Project>, SourceError> {
662        self.metadata_updates(MetadataRecord::Project)?;
663        let result: ProjectResult = self
664            .ask("set_project_metadata", metadata_params(id, key, value))
665            .await?;
666        Ok(result.project)
667    }
668
669    async fn set_document_metadata(
670        &self,
671        id: &NativeId,
672        key: &MetadataKey,
673        value: &Value,
674    ) -> Result<Option<Document>, SourceError> {
675        self.metadata_updates(MetadataRecord::Document)?;
676        let result: DocumentResult = self
677            .ask("set_document_metadata", metadata_params(id, key, value))
678            .await?;
679        Ok(result.document)
680    }
681
682    async fn delete_task(&self, id: &NativeId) -> Result<(), SourceError> {
683        let _: IgnoredResult = self
684            .ask("delete_task", params(&DeleteParams { id: id.clone() }))
685            .await?;
686        Ok(())
687    }
688
689    async fn delete_project(&self, id: &NativeId) -> Result<(), SourceError> {
690        let _: IgnoredResult = self
691            .ask("delete_project", params(&DeleteParams { id: id.clone() }))
692            .await?;
693        Ok(())
694    }
695
696    async fn get_document(&self, id: &NativeId) -> Result<Option<Document>, SourceError> {
697        let result: DocumentResult = self
698            .ask("get_document", params(&IdParams { id: id.clone() }))
699            .await?;
700        Ok(result.document)
701    }
702
703    async fn query_documents(
704        &self,
705        query: &DocumentQuery,
706        page: &PageRequest,
707    ) -> Result<Page<Document>, SourceError> {
708        self.ask(
709            "query_documents",
710            params(&DocumentQueryParams {
711                query: query.clone(),
712                page: page.clone(),
713            }),
714        )
715        .await
716    }
717
718    async fn write_document(&self, write: &ItemWrite<Document>) -> Result<NativeId, SourceError> {
719        let result: WriteResult = self
720            .ask(
721                "write_document",
722                params(&DocumentWriteParams {
723                    write: write.clone(),
724                }),
725            )
726            .await?;
727        Ok(result.id)
728    }
729
730    async fn delete_document(&self, id: &NativeId) -> Result<(), SourceError> {
731        let _: IgnoredResult = self
732            .ask("delete_document", params(&DeleteParams { id: id.clone() }))
733            .await?;
734        Ok(())
735    }
736
737    async fn task_comments(
738        &self,
739        task: &NativeId,
740        page: &PageRequest,
741    ) -> Result<Option<Page<Comment>>, SourceError> {
742        let result: CommentsResult = self
743            .ask(
744                "task_comments",
745                params(&CommentsParams {
746                    task: task.clone(),
747                    page: page.clone(),
748                }),
749            )
750            .await?;
751        Ok(result.page)
752    }
753
754    async fn add_comment(
755        &self,
756        task: &NativeId,
757        comment: &NewComment,
758    ) -> Result<Option<Comment>, SourceError> {
759        let result: CommentResult = self
760            .ask(
761                "add_comment",
762                params(&AddCommentParams {
763                    task: task.clone(),
764                    comment: comment.clone(),
765                }),
766            )
767            .await?;
768        Ok(result.comment)
769    }
770
771    async fn edit_comment(
772        &self,
773        task: &NativeId,
774        comment: &NativeId,
775        body: &CommentBody,
776    ) -> Result<Option<Comment>, SourceError> {
777        let result: CommentResult = self
778            .ask(
779                "edit_comment",
780                params(&EditCommentParams {
781                    task: task.clone(),
782                    comment: comment.clone(),
783                    body: body.clone(),
784                }),
785            )
786            .await?;
787        Ok(result.comment)
788    }
789
790    async fn delete_comment(
791        &self,
792        task: &NativeId,
793        comment: &NativeId,
794    ) -> Result<Option<NativeId>, SourceError> {
795        let result: DeletedCommentResult = self
796            .ask(
797                "delete_comment",
798                params(&DeleteCommentParams {
799                    task: task.clone(),
800                    comment: comment.clone(),
801                }),
802            )
803            .await?;
804        Ok(result.deleted)
805    }
806
807    async fn metering(&self) -> Result<Option<Metering>, SourceError> {
808        // Never sent to a plugin that did not declare it (§3.4), which is what lets a
809        // plugin written before there was metering go on working without an edit.
810        if !self.meters {
811            return Ok(None);
812        }
813        let result: MeteringResult = self.ask("metering", json!({})).await?;
814        Ok(result.metering)
815    }
816}
817
818/// The object §4.10 answers with, decoded so that `ask` has a type to hand back.
819///
820/// A named type rather than `serde_json::Value` so a plugin answering with something other
821/// than an object is still refused where every other method's answer is. It does **not**
822/// require that object to be empty, and no `deny_unknown_fields` belongs here: §2.1 is that
823/// a reader ignores members it does not know, at every level, which is what lets a later
824/// version add an optional one without a version bump. Refusing an unknown member here
825/// would refuse that plugin outright, and would be the only type of this boundary that did.
826#[derive(serde::Deserialize)]
827struct IgnoredResult {}
828
829/// One method's parameters as the object the envelope carries.
830///
831/// Every parameter type in `wire` is built from contract types that all serialize, so
832/// this cannot fail for a reason a caller could act on.
833fn params<T: serde::Serialize>(value: &T) -> Value {
834    serde_json::to_value(value).expect("method parameters are plain data")
835}
836
837/// The parameters of any of the three narrow metadata writes (§4.18).
838fn metadata_params(id: &NativeId, key: &MetadataKey, value: &Value) -> Value {
839    params(&MetadataParams {
840        id: id.clone(),
841        key: key.clone(),
842        value: value.clone(),
843    })
844}