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