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