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