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