1use std::collections::BTreeMap;
12use std::num::NonZeroU64;
13use std::path::Path;
14use std::time::Duration;
15
16use async_trait::async_trait;
17use onetaskgraph_plugin_api::{
18 Capabilities, Comment, CommentBody, DependencyEdge, Direction, Document, DocumentQuery, Health,
19 ItemWrite, Label, MetadataKey, MetadataRecord, Metering, NativeId, NewComment, Page,
20 PageRequest, Project, ProjectQuery, SourceError, SourceName, Status, StatusCategory, Task,
21 TaskQuery, TaskRef, TaskSource, WriteSupport, unwritable_metadata,
22};
23use serde::Deserialize;
24use serde_json::{Value, json};
25
26use super::connection::{Connection, Peer};
27use super::wire::{
28 AddCommentParams, CommentResult, CommentsParams, CommentsResult, DeleteCommentParams,
29 DeleteParams, DeletedCommentResult, DeliveredByParams, DeliveredByResult, DependencyParams,
30 DocumentDir, DocumentQueryParams, DocumentResult, DocumentWriteParams, EditCommentParams,
31 EngineIdentity, IdParams, InitializeParams, InitializeResult, LabelParams, MetadataParams,
32 MeteringResult, PROTOCOL_VERSION, ProjectQueryParams, ProjectResult, ProjectWriteParams,
33 Request, StatusParams, StatusResult, TaskQueryParams, TaskResult, TaskWriteParams, WriteResult,
34 after_the_first_vocabulary, knows_every_category, spelled, vocabulary,
35};
36
37const HANDSHAKE_ID: &str = "0";
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub struct RequestDeadline(NonZeroU64);
45
46impl RequestDeadline {
47 pub const DEFAULT: Self = Self(NonZeroU64::new(30_000).expect("non-zero default"));
49
50 #[must_use]
52 pub const fn from_millis(milliseconds: NonZeroU64) -> Self {
53 Self(milliseconds)
54 }
55
56 #[must_use]
58 pub const fn milliseconds(self) -> NonZeroU64 {
59 self.0
60 }
61
62 fn duration(self) -> Duration {
63 Duration::from_millis(self.0.get())
64 }
65}
66
67#[derive(Debug, Clone, Copy)]
71struct Deadlines {
72 handshake: RequestDeadline,
74 requests: RequestDeadline,
76}
77
78pub struct SubprocessSource {
80 kind: &'static str,
88 capabilities: Capabilities,
90 writes: WriteSupport,
96 meters: bool,
101 knows_every_category: bool,
106 task_updates: bool,
109 metadata_updates: bool,
112 connection: Connection,
114}
115
116impl std::fmt::Debug for SubprocessSource {
117 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
120 f.debug_struct("SubprocessSource")
121 .field("kind", &self.kind)
122 .finish_non_exhaustive()
123 }
124}
125
126impl SubprocessSource {
127 pub fn connect(
136 program: &str,
137 args: &[String],
138 name: &SourceName,
139 config: &Value,
140 secrets: BTreeMap<String, String>,
141 ) -> Result<Self, SourceError> {
142 Self::connect_with_deadline(
143 program,
144 args,
145 name,
146 config,
147 secrets,
148 RequestDeadline::DEFAULT,
149 )
150 }
151
152 pub fn connect_with_deadline(
158 program: &str,
159 args: &[String],
160 name: &SourceName,
161 config: &Value,
162 secrets: BTreeMap<String, String>,
163 deadline: RequestDeadline,
164 ) -> Result<Self, SourceError> {
165 Self::connect_with_deadlines(program, args, name, config, secrets, deadline, deadline)
166 }
167
168 pub fn connect_with_deadlines(
187 program: &str,
188 args: &[String],
189 name: &SourceName,
190 config: &Value,
191 secrets: BTreeMap<String, String>,
192 handshake: RequestDeadline,
193 requests: RequestDeadline,
194 ) -> Result<Self, SourceError> {
195 Self::connect_bounded(
196 program,
197 args,
198 name,
199 config,
200 secrets,
201 Deadlines {
202 handshake,
203 requests,
204 },
205 None,
206 )
207 }
208
209 pub fn connect_from_document(
219 program: &str,
220 args: &[String],
221 name: &SourceName,
222 config: &Value,
223 secrets: BTreeMap<String, String>,
224 deadline: RequestDeadline,
225 document_dir: Option<&Path>,
226 ) -> Result<Self, SourceError> {
227 Self::connect_bounded(
228 program,
229 args,
230 name,
231 config,
232 secrets,
233 Deadlines {
234 handshake: deadline,
235 requests: deadline,
236 },
237 document_dir,
238 )
239 }
240
241 fn connect_bounded(
242 program: &str,
243 args: &[String],
244 name: &SourceName,
245 config: &Value,
246 secrets: BTreeMap<String, String>,
247 deadlines: Deadlines,
248 document_dir: Option<&Path>,
249 ) -> Result<Self, SourceError> {
250 let document_dir = document_dir
251 .map(|directory| {
252 DocumentDir::new(directory).map_err(|problem| SourceError::Config {
253 message: format!(
254 "source {name}: its settings are measured from the directory holding \
255 the configuration document that set them, and {problem}; give the \
256 settings absolute paths, or move the document under a directory \
257 whose name is valid UTF-8"
258 ),
259 })
260 })
261 .transpose()?;
262 Self::adopt(
263 Peer::spawn(
264 program,
265 args,
266 deadlines.handshake.duration(),
267 deadlines.requests.duration(),
268 )?,
269 name,
270 config,
271 secrets,
272 document_dir,
273 )
274 }
275
276 pub fn over(
291 to_plugin: impl std::io::Write + Send + 'static,
292 from_plugin: impl std::io::Read + Send + 'static,
293 name: &SourceName,
294 config: &Value,
295 secrets: BTreeMap<String, String>,
296 ) -> Result<Self, SourceError> {
297 Self::over_with_request_deadline(
298 to_plugin,
299 from_plugin,
300 name,
301 config,
302 secrets,
303 RequestDeadline::DEFAULT,
304 )
305 }
306
307 pub fn over_with_request_deadline(
313 to_plugin: impl std::io::Write + Send + 'static,
314 from_plugin: impl std::io::Read + Send + 'static,
315 name: &SourceName,
316 config: &Value,
317 secrets: BTreeMap<String, String>,
318 deadline: RequestDeadline,
319 ) -> Result<Self, SourceError> {
320 Self::adopt(
321 Peer::over(to_plugin, from_plugin, deadline.duration()),
322 name,
323 config,
324 secrets,
325 None,
326 )
327 }
328
329 fn adopt(
331 mut peer: Peer,
332 name: &SourceName,
333 config: &Value,
334 secrets: BTreeMap<String, String>,
335 document_dir: Option<DocumentDir>,
336 ) -> Result<Self, SourceError> {
337 let result = Self::handshake(&mut peer, name, config, secrets, document_dir);
338 let InitializeResult {
339 protocol_version,
340 kind,
341 capabilities,
342 writes,
343 meters,
344 statuses,
345 task_updates,
346 metadata_updates,
347 } = match result {
348 Ok(result) => result,
349 Err(error) => return Err(with_diagnostics(error, &mut peer)),
350 };
351 let kind = kind.into_string();
352 if protocol_version != Some(PROTOCOL_VERSION) {
353 return Err(SourceError::Config {
354 message: match protocol_version {
355 Some(spoken) => format!(
356 "the {kind:?} plugin was asked for protocol version \
357 {PROTOCOL_VERSION} and answered in version {spoken}; the two are \
358 incompatible and this engine does not guess between them"
359 ),
360 None => format!(
361 "the {kind:?} plugin did not say which protocol version it \
362 answered in; this engine speaks version {PROTOCOL_VERSION} and \
363 does not guess"
364 ),
365 },
366 });
367 }
368 Ok(Self {
369 kind: String::leak(kind),
370 capabilities,
371 writes: writes.unwrap_or(WriteSupport::Unsupported),
372 meters,
373 knows_every_category: knows_every_category(statuses.as_deref()),
374 task_updates,
375 metadata_updates,
376 connection: Connection::adopt(peer),
377 })
378 }
379
380 fn handshake(
382 peer: &mut Peer,
383 name: &SourceName,
384 config: &Value,
385 secrets: BTreeMap<String, String>,
386 document_dir: Option<DocumentDir>,
387 ) -> Result<InitializeResult, SourceError> {
388 let params = InitializeParams {
389 protocol_version: PROTOCOL_VERSION,
390 engine: EngineIdentity {
391 name: "onetaskgraph".to_owned(),
392 version: env!("CARGO_PKG_VERSION").to_owned(),
393 },
394 source_name: name.as_str().to_owned(),
395 config: config.clone(),
396 secrets,
397 statuses: Some(vocabulary()),
398 document_dir,
399 };
400 let request = Request {
401 id: HANDSHAKE_ID.to_owned(),
402 method: "initialize".to_owned(),
403 params: serde_json::to_value(¶ms).expect("a handshake is plain data"),
406 };
407 let line = peer.exchange(
408 &serde_json::to_string(&request).expect("a handshake request is plain data"),
409 )?;
410 let response: super::wire::Response =
411 serde_json::from_str(&line).map_err(|error| SourceError::Malformed {
412 message: format!(
413 "the plugin's handshake answer is not a response envelope: {error}"
414 ),
415 })?;
416 if response.id != HANDSHAKE_ID {
421 return Err(SourceError::Malformed {
422 message: format!(
423 "the plugin answered the handshake with an envelope addressed to {:?} \
424 rather than to {HANDSHAKE_ID:?}",
425 response.id
426 ),
427 });
428 }
429 let outcome = response.outcome().ok_or_else(|| SourceError::Malformed {
430 message: "the plugin's handshake answer carried both a result and an error, or \
431 neither"
432 .to_owned(),
433 })?;
434 let result = outcome?;
435 serde_json::from_value(result).map_err(|error| SourceError::Malformed {
436 message: format!("the plugin's handshake answer is not an initialize result: {error}"),
437 })
438 }
439
440 fn statuses_for(&self, statuses: &[StatusCategory]) -> Option<Vec<StatusCategory>> {
447 if self.knows_every_category {
448 return Some(statuses.to_vec());
449 }
450 let known: Vec<StatusCategory> = statuses
451 .iter()
452 .copied()
453 .filter(|category| !after_the_first_vocabulary(*category))
454 .collect();
455 (known.len() == statuses.len() || !known.is_empty()).then_some(known)
456 }
457
458 fn knows(&self, category: StatusCategory) -> Result<(), SourceError> {
461 if self.knows_every_category || !after_the_first_vocabulary(category) {
462 return Ok(());
463 }
464 Err(SourceError::Refused {
465 message: format!(
466 "the {:?} plugin's handshake does not list the status category {}, so this \
467 engine does not hand it one (docs/plugin-protocol.md §3.5); next: upgrade the \
468 plugin to one whose handshake lists it, or use a category it knows",
469 self.kind,
470 spelled(category)
471 ),
472 })
473 }
474
475 fn updates(&self, what: &str) -> Result<(), SourceError> {
477 if self.task_updates {
478 return Ok(());
479 }
480 Err(SourceError::Refused {
481 message: format!(
482 "the {:?} plugin's handshake does not say it answers the narrow task writes, so \
483 this engine does not send it {what} (docs/plugin-protocol.md §3.6); next: \
484 upgrade the plugin to one whose handshake sets task_updates",
485 self.kind
486 ),
487 })
488 }
489
490 fn metadata_updates(&self, record: MetadataRecord) -> Result<(), SourceError> {
493 if self.metadata_updates {
494 return Ok(());
495 }
496 Err(unwritable_metadata(self.kind, record))
497 }
498
499 fn writable_task(&self, task: &Task) -> Result<(), SourceError> {
501 self.knows(task.status.category)?;
502 if !task.delivers.is_empty() || !task.delivered_by.is_empty() {
503 self.updates("a task carrying delivers or delivered_by")?;
504 }
505 Ok(())
506 }
507
508 async fn ask<T: for<'de> Deserialize<'de>>(
510 &self,
511 method: &str,
512 params: Value,
513 ) -> Result<T, SourceError> {
514 let result = self.connection.call(method, params).await?;
515 serde_json::from_value(result).map_err(|error| SourceError::Malformed {
516 message: format!(
517 "the plugin's answer to {method} is not the shape it promises: {error}"
518 ),
519 })
520 }
521}
522
523fn with_diagnostics(error: SourceError, peer: &mut Peer) -> SourceError {
528 let said = peer.said();
529 if said.is_empty() {
530 return error;
531 }
532 let message = format!("{error}; the plugin wrote: {said}");
533 match error {
534 SourceError::RateLimited {
537 retry_after_seconds,
538 ..
539 } => SourceError::RateLimited {
540 retry_after_seconds,
541 message: Some(message),
542 },
543 SourceError::Config { .. } => SourceError::Config { message },
544 SourceError::Auth { .. } => SourceError::Auth { message },
545 SourceError::Refused { .. } => SourceError::Refused { message },
546 SourceError::Malformed { .. } => SourceError::Malformed { message },
547 SourceError::Unavailable { .. } => SourceError::Unavailable { message },
548 }
549}
550
551#[async_trait]
552impl TaskSource for SubprocessSource {
553 fn kind(&self) -> &'static str {
554 self.kind
555 }
556
557 fn capabilities(&self) -> Capabilities {
558 self.capabilities.clone()
559 }
560
561 async fn health(&self) -> Result<Health, SourceError> {
562 self.ask("health", json!({})).await
563 }
564
565 async fn get_task(&self, id: &NativeId) -> Result<Option<Task>, SourceError> {
566 let result: TaskResult = self
567 .ask("get_task", params(&IdParams { id: id.clone() }))
568 .await?;
569 Ok(result.task)
570 }
571
572 async fn get_project(&self, id: &NativeId) -> Result<Option<Project>, SourceError> {
573 let result: ProjectResult = self
574 .ask("get_project", params(&IdParams { id: id.clone() }))
575 .await?;
576 Ok(result.project)
577 }
578
579 async fn query_tasks(
580 &self,
581 query: &TaskQuery,
582 page: &PageRequest,
583 ) -> Result<Page<Task>, SourceError> {
584 let Some(statuses) = self.statuses_for(&query.statuses) else {
585 return Ok(Page::last(Vec::new()));
586 };
587 self.ask(
588 "query_tasks",
589 params(&TaskQueryParams {
590 query: TaskQuery {
591 statuses,
592 ..query.clone()
593 },
594 page: page.clone(),
595 }),
596 )
597 .await
598 }
599
600 async fn query_projects(
601 &self,
602 query: &ProjectQuery,
603 page: &PageRequest,
604 ) -> Result<Page<Project>, SourceError> {
605 let Some(statuses) = self.statuses_for(&query.statuses) else {
606 return Ok(Page::last(Vec::new()));
607 };
608 self.ask(
609 "query_projects",
610 params(&ProjectQueryParams {
611 query: ProjectQuery {
612 statuses,
613 ..query.clone()
614 },
615 page: page.clone(),
616 }),
617 )
618 .await
619 }
620
621 async fn labels(&self, page: &PageRequest) -> Result<Page<Label>, SourceError> {
622 self.ask("labels", params(&LabelParams { page: page.clone() }))
623 .await
624 }
625
626 async fn task_dependencies(
627 &self,
628 id: &NativeId,
629 direction: Direction,
630 page: &PageRequest,
631 ) -> Result<Page<DependencyEdge>, SourceError> {
632 self.ask(
633 "task_dependencies",
634 params(&DependencyParams {
635 id: id.clone(),
636 direction,
637 page: page.clone(),
638 }),
639 )
640 .await
641 }
642
643 async fn project_dependencies(
644 &self,
645 id: &NativeId,
646 direction: Direction,
647 page: &PageRequest,
648 ) -> Result<Page<DependencyEdge>, SourceError> {
649 self.ask(
650 "project_dependencies",
651 params(&DependencyParams {
652 id: id.clone(),
653 direction,
654 page: page.clone(),
655 }),
656 )
657 .await
658 }
659
660 fn writes(&self) -> WriteSupport {
661 self.writes
662 }
663
664 async fn write_task(&self, write: &ItemWrite<Task>) -> Result<NativeId, SourceError> {
665 self.writable_task(&write.item)?;
666 let result: WriteResult = self
667 .ask(
668 "write_task",
669 params(&TaskWriteParams {
670 write: write.clone(),
671 }),
672 )
673 .await?;
674 Ok(result.id)
675 }
676
677 async fn write_project(&self, write: &ItemWrite<Project>) -> Result<NativeId, SourceError> {
678 self.knows(write.item.status.category)?;
679 let result: WriteResult = self
680 .ask(
681 "write_project",
682 params(&ProjectWriteParams {
683 write: write.clone(),
684 }),
685 )
686 .await?;
687 Ok(result.id)
688 }
689
690 async fn set_task_status(
691 &self,
692 id: &NativeId,
693 category: StatusCategory,
694 ) -> Result<Option<Status>, SourceError> {
695 self.updates("set_task_status")?;
696 self.knows(category)?;
697 let result: StatusResult = self
698 .ask(
699 "set_task_status",
700 params(&StatusParams {
701 id: id.clone(),
702 category,
703 }),
704 )
705 .await?;
706 Ok(result.status)
707 }
708
709 async fn set_delivered_by(
710 &self,
711 id: &NativeId,
712 delivered_by: &[TaskRef],
713 ) -> Result<Option<()>, SourceError> {
714 self.updates("set_delivered_by")?;
715 let result: DeliveredByResult = self
716 .ask(
717 "set_delivered_by",
718 params(&DeliveredByParams {
719 id: id.clone(),
720 delivered_by: delivered_by.to_vec(),
721 }),
722 )
723 .await?;
724 Ok(result.delivered_by.map(|_| ()))
725 }
726
727 async fn set_task_metadata(
728 &self,
729 id: &NativeId,
730 key: &MetadataKey,
731 value: &Value,
732 ) -> Result<Option<Task>, SourceError> {
733 self.metadata_updates(MetadataRecord::Task)?;
734 let result: TaskResult = self
735 .ask("set_task_metadata", metadata_params(id, key, value))
736 .await?;
737 Ok(result.task)
738 }
739
740 async fn set_project_metadata(
741 &self,
742 id: &NativeId,
743 key: &MetadataKey,
744 value: &Value,
745 ) -> Result<Option<Project>, SourceError> {
746 self.metadata_updates(MetadataRecord::Project)?;
747 let result: ProjectResult = self
748 .ask("set_project_metadata", metadata_params(id, key, value))
749 .await?;
750 Ok(result.project)
751 }
752
753 async fn set_document_metadata(
754 &self,
755 id: &NativeId,
756 key: &MetadataKey,
757 value: &Value,
758 ) -> Result<Option<Document>, SourceError> {
759 self.metadata_updates(MetadataRecord::Document)?;
760 let result: DocumentResult = self
761 .ask("set_document_metadata", metadata_params(id, key, value))
762 .await?;
763 Ok(result.document)
764 }
765
766 async fn delete_task(&self, id: &NativeId) -> Result<(), SourceError> {
767 let _: IgnoredResult = self
768 .ask("delete_task", params(&DeleteParams { id: id.clone() }))
769 .await?;
770 Ok(())
771 }
772
773 async fn delete_project(&self, id: &NativeId) -> Result<(), SourceError> {
774 let _: IgnoredResult = self
775 .ask("delete_project", params(&DeleteParams { id: id.clone() }))
776 .await?;
777 Ok(())
778 }
779
780 async fn get_document(&self, id: &NativeId) -> Result<Option<Document>, SourceError> {
781 let result: DocumentResult = self
782 .ask("get_document", params(&IdParams { id: id.clone() }))
783 .await?;
784 Ok(result.document)
785 }
786
787 async fn query_documents(
788 &self,
789 query: &DocumentQuery,
790 page: &PageRequest,
791 ) -> Result<Page<Document>, SourceError> {
792 self.ask(
793 "query_documents",
794 params(&DocumentQueryParams {
795 query: query.clone(),
796 page: page.clone(),
797 }),
798 )
799 .await
800 }
801
802 async fn write_document(&self, write: &ItemWrite<Document>) -> Result<NativeId, SourceError> {
803 let result: WriteResult = self
804 .ask(
805 "write_document",
806 params(&DocumentWriteParams {
807 write: write.clone(),
808 }),
809 )
810 .await?;
811 Ok(result.id)
812 }
813
814 async fn delete_document(&self, id: &NativeId) -> Result<(), SourceError> {
815 let _: IgnoredResult = self
816 .ask("delete_document", params(&DeleteParams { id: id.clone() }))
817 .await?;
818 Ok(())
819 }
820
821 async fn task_comments(
822 &self,
823 task: &NativeId,
824 page: &PageRequest,
825 ) -> Result<Option<Page<Comment>>, SourceError> {
826 let result: CommentsResult = self
827 .ask(
828 "task_comments",
829 params(&CommentsParams {
830 task: task.clone(),
831 page: page.clone(),
832 }),
833 )
834 .await?;
835 Ok(result.page)
836 }
837
838 async fn add_comment(
839 &self,
840 task: &NativeId,
841 comment: &NewComment,
842 ) -> Result<Option<Comment>, SourceError> {
843 let result: CommentResult = self
844 .ask(
845 "add_comment",
846 params(&AddCommentParams {
847 task: task.clone(),
848 comment: comment.clone(),
849 }),
850 )
851 .await?;
852 Ok(result.comment)
853 }
854
855 async fn edit_comment(
856 &self,
857 task: &NativeId,
858 comment: &NativeId,
859 body: &CommentBody,
860 ) -> Result<Option<Comment>, SourceError> {
861 let result: CommentResult = self
862 .ask(
863 "edit_comment",
864 params(&EditCommentParams {
865 task: task.clone(),
866 comment: comment.clone(),
867 body: body.clone(),
868 }),
869 )
870 .await?;
871 Ok(result.comment)
872 }
873
874 async fn delete_comment(
875 &self,
876 task: &NativeId,
877 comment: &NativeId,
878 ) -> Result<Option<NativeId>, SourceError> {
879 let result: DeletedCommentResult = self
880 .ask(
881 "delete_comment",
882 params(&DeleteCommentParams {
883 task: task.clone(),
884 comment: comment.clone(),
885 }),
886 )
887 .await?;
888 Ok(result.deleted)
889 }
890
891 async fn metering(&self) -> Result<Option<Metering>, SourceError> {
892 if !self.meters {
895 return Ok(None);
896 }
897 let result: MeteringResult = self.ask("metering", json!({})).await?;
898 Ok(result.metering)
899 }
900}
901
902#[derive(serde::Deserialize)]
911struct IgnoredResult {}
912
913fn params<T: serde::Serialize>(value: &T) -> Value {
918 serde_json::to_value(value).expect("method parameters are plain data")
919}
920
921fn metadata_params(id: &NativeId, key: &MetadataKey, value: &Value) -> Value {
923 params(&MetadataParams {
924 id: id.clone(),
925 key: key.clone(),
926 value: value.clone(),
927 })
928}