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, 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
38const HANDSHAKE_ID: &str = "0";
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub struct RequestDeadline(NonZeroU64);
46
47impl RequestDeadline {
48 pub const DEFAULT: Self = Self(NonZeroU64::new(30_000).expect("non-zero default"));
50
51 #[must_use]
53 pub const fn from_millis(milliseconds: NonZeroU64) -> Self {
54 Self(milliseconds)
55 }
56
57 #[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#[derive(Debug, Clone, Copy)]
72struct Deadlines {
73 handshake: RequestDeadline,
75 requests: RequestDeadline,
77}
78
79pub struct SubprocessSource {
81 kind: &'static str,
89 capabilities: Capabilities,
91 writes: WriteSupport,
97 meters: bool,
102 knows_every_category: bool,
107 task_updates: bool,
110 metadata_updates: bool,
113 content_updates: bool,
116 connection: Connection,
118}
119
120impl std::fmt::Debug for SubprocessSource {
121 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 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 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 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 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 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 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 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 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 params: serde_json::to_value(¶ms).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 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 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 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 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 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 fn writable_task(&self, task: &Task) -> Result<(), SourceError> {
507 self.knows(task.status.category)?;
508 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 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
535fn 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 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 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 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 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#[derive(serde::Deserialize)]
974struct IgnoredResult {}
975
976fn params<T: serde::Serialize>(value: &T) -> Value {
981 serde_json::to_value(value).expect("method parameters are plain data")
982}
983
984fn 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}