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
67pub struct SubprocessSource {
69 kind: &'static str,
77 capabilities: Capabilities,
79 writes: WriteSupport,
85 meters: bool,
90 knows_every_category: bool,
95 task_updates: bool,
98 metadata_updates: bool,
101 connection: Connection,
103}
104
105impl std::fmt::Debug for SubprocessSource {
106 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
109 f.debug_struct("SubprocessSource")
110 .field("kind", &self.kind)
111 .finish_non_exhaustive()
112 }
113}
114
115impl SubprocessSource {
116 pub fn connect(
125 program: &str,
126 args: &[String],
127 name: &SourceName,
128 config: &Value,
129 secrets: BTreeMap<String, String>,
130 ) -> Result<Self, SourceError> {
131 Self::connect_with_deadline(
132 program,
133 args,
134 name,
135 config,
136 secrets,
137 RequestDeadline::DEFAULT,
138 )
139 }
140
141 pub fn connect_with_deadline(
143 program: &str,
144 args: &[String],
145 name: &SourceName,
146 config: &Value,
147 secrets: BTreeMap<String, String>,
148 deadline: RequestDeadline,
149 ) -> Result<Self, SourceError> {
150 Self::connect_from_document(program, args, name, config, secrets, deadline, None)
151 }
152
153 pub fn connect_from_document(
163 program: &str,
164 args: &[String],
165 name: &SourceName,
166 config: &Value,
167 secrets: BTreeMap<String, String>,
168 deadline: RequestDeadline,
169 document_dir: Option<&Path>,
170 ) -> Result<Self, SourceError> {
171 let document_dir = document_dir
172 .map(|directory| {
173 DocumentDir::new(directory).map_err(|problem| SourceError::Config {
174 message: format!(
175 "source {name}: its settings are measured from the directory holding \
176 the configuration document that set them, and {problem}; give the \
177 settings absolute paths, or move the document under a directory \
178 whose name is valid UTF-8"
179 ),
180 })
181 })
182 .transpose()?;
183 Self::adopt(
184 Peer::spawn(program, args, deadline.duration())?,
185 name,
186 config,
187 secrets,
188 document_dir,
189 )
190 }
191
192 pub fn over(
207 to_plugin: impl std::io::Write + Send + 'static,
208 from_plugin: impl std::io::Read + Send + 'static,
209 name: &SourceName,
210 config: &Value,
211 secrets: BTreeMap<String, String>,
212 ) -> Result<Self, SourceError> {
213 Self::over_with_request_deadline(
214 to_plugin,
215 from_plugin,
216 name,
217 config,
218 secrets,
219 RequestDeadline::DEFAULT,
220 )
221 }
222
223 pub fn over_with_request_deadline(
229 to_plugin: impl std::io::Write + Send + 'static,
230 from_plugin: impl std::io::Read + Send + 'static,
231 name: &SourceName,
232 config: &Value,
233 secrets: BTreeMap<String, String>,
234 deadline: RequestDeadline,
235 ) -> Result<Self, SourceError> {
236 Self::adopt(
237 Peer::over(to_plugin, from_plugin, deadline.duration()),
238 name,
239 config,
240 secrets,
241 None,
242 )
243 }
244
245 fn adopt(
247 mut peer: Peer,
248 name: &SourceName,
249 config: &Value,
250 secrets: BTreeMap<String, String>,
251 document_dir: Option<DocumentDir>,
252 ) -> Result<Self, SourceError> {
253 let result = Self::handshake(&mut peer, name, config, secrets, document_dir);
254 let InitializeResult {
255 protocol_version,
256 kind,
257 capabilities,
258 writes,
259 meters,
260 statuses,
261 task_updates,
262 metadata_updates,
263 } = match result {
264 Ok(result) => result,
265 Err(error) => return Err(with_diagnostics(error, &mut peer)),
266 };
267 let kind = kind.into_string();
268 if protocol_version != Some(PROTOCOL_VERSION) {
269 return Err(SourceError::Config {
270 message: match protocol_version {
271 Some(spoken) => format!(
272 "the {kind:?} plugin was asked for protocol version \
273 {PROTOCOL_VERSION} and answered in version {spoken}; the two are \
274 incompatible and this engine does not guess between them"
275 ),
276 None => format!(
277 "the {kind:?} plugin did not say which protocol version it \
278 answered in; this engine speaks version {PROTOCOL_VERSION} and \
279 does not guess"
280 ),
281 },
282 });
283 }
284 Ok(Self {
285 kind: String::leak(kind),
286 capabilities,
287 writes: writes.unwrap_or(WriteSupport::Unsupported),
288 meters,
289 knows_every_category: knows_every_category(statuses.as_deref()),
290 task_updates,
291 metadata_updates,
292 connection: Connection::adopt(peer),
293 })
294 }
295
296 fn handshake(
298 peer: &mut Peer,
299 name: &SourceName,
300 config: &Value,
301 secrets: BTreeMap<String, String>,
302 document_dir: Option<DocumentDir>,
303 ) -> Result<InitializeResult, SourceError> {
304 let params = InitializeParams {
305 protocol_version: PROTOCOL_VERSION,
306 engine: EngineIdentity {
307 name: "onetaskgraph".to_owned(),
308 version: env!("CARGO_PKG_VERSION").to_owned(),
309 },
310 source_name: name.as_str().to_owned(),
311 config: config.clone(),
312 secrets,
313 statuses: Some(vocabulary()),
314 document_dir,
315 };
316 let request = Request {
317 id: HANDSHAKE_ID.to_owned(),
318 method: "initialize".to_owned(),
319 params: serde_json::to_value(¶ms).expect("a handshake is plain data"),
322 };
323 let line = peer.exchange(
324 &serde_json::to_string(&request).expect("a handshake request is plain data"),
325 )?;
326 let response: super::wire::Response =
327 serde_json::from_str(&line).map_err(|error| SourceError::Malformed {
328 message: format!(
329 "the plugin's handshake answer is not a response envelope: {error}"
330 ),
331 })?;
332 if response.id != HANDSHAKE_ID {
337 return Err(SourceError::Malformed {
338 message: format!(
339 "the plugin answered the handshake with an envelope addressed to {:?} \
340 rather than to {HANDSHAKE_ID:?}",
341 response.id
342 ),
343 });
344 }
345 let outcome = response.outcome().ok_or_else(|| SourceError::Malformed {
346 message: "the plugin's handshake answer carried both a result and an error, or \
347 neither"
348 .to_owned(),
349 })?;
350 let result = outcome?;
351 serde_json::from_value(result).map_err(|error| SourceError::Malformed {
352 message: format!("the plugin's handshake answer is not an initialize result: {error}"),
353 })
354 }
355
356 fn statuses_for(&self, statuses: &[StatusCategory]) -> Option<Vec<StatusCategory>> {
363 if self.knows_every_category {
364 return Some(statuses.to_vec());
365 }
366 let known: Vec<StatusCategory> = statuses
367 .iter()
368 .copied()
369 .filter(|category| !after_the_first_vocabulary(*category))
370 .collect();
371 (known.len() == statuses.len() || !known.is_empty()).then_some(known)
372 }
373
374 fn knows(&self, category: StatusCategory) -> Result<(), SourceError> {
377 if self.knows_every_category || !after_the_first_vocabulary(category) {
378 return Ok(());
379 }
380 Err(SourceError::Refused {
381 message: format!(
382 "the {:?} plugin's handshake does not list the status category {}, so this \
383 engine does not hand it one (docs/plugin-protocol.md §3.5); next: upgrade the \
384 plugin to one whose handshake lists it, or use a category it knows",
385 self.kind,
386 spelled(category)
387 ),
388 })
389 }
390
391 fn updates(&self, what: &str) -> Result<(), SourceError> {
393 if self.task_updates {
394 return Ok(());
395 }
396 Err(SourceError::Refused {
397 message: format!(
398 "the {:?} plugin's handshake does not say it answers the narrow task writes, so \
399 this engine does not send it {what} (docs/plugin-protocol.md §3.6); next: \
400 upgrade the plugin to one whose handshake sets task_updates",
401 self.kind
402 ),
403 })
404 }
405
406 fn metadata_updates(&self, record: MetadataRecord) -> Result<(), SourceError> {
409 if self.metadata_updates {
410 return Ok(());
411 }
412 Err(unwritable_metadata(self.kind, record))
413 }
414
415 fn writable_task(&self, task: &Task) -> Result<(), SourceError> {
417 self.knows(task.status.category)?;
418 if !task.delivers.is_empty() || !task.delivered_by.is_empty() {
419 self.updates("a task carrying delivers or delivered_by")?;
420 }
421 Ok(())
422 }
423
424 async fn ask<T: for<'de> Deserialize<'de>>(
426 &self,
427 method: &str,
428 params: Value,
429 ) -> Result<T, SourceError> {
430 let result = self.connection.call(method, params).await?;
431 serde_json::from_value(result).map_err(|error| SourceError::Malformed {
432 message: format!(
433 "the plugin's answer to {method} is not the shape it promises: {error}"
434 ),
435 })
436 }
437}
438
439fn with_diagnostics(error: SourceError, peer: &mut Peer) -> SourceError {
444 let said = peer.said();
445 if said.is_empty() {
446 return error;
447 }
448 let message = format!("{error}; the plugin wrote: {said}");
449 match error {
450 SourceError::RateLimited {
453 retry_after_seconds,
454 ..
455 } => SourceError::RateLimited {
456 retry_after_seconds,
457 message: Some(message),
458 },
459 SourceError::Config { .. } => SourceError::Config { message },
460 SourceError::Auth { .. } => SourceError::Auth { message },
461 SourceError::Refused { .. } => SourceError::Refused { message },
462 SourceError::Malformed { .. } => SourceError::Malformed { message },
463 SourceError::Unavailable { .. } => SourceError::Unavailable { message },
464 }
465}
466
467#[async_trait]
468impl TaskSource for SubprocessSource {
469 fn kind(&self) -> &'static str {
470 self.kind
471 }
472
473 fn capabilities(&self) -> Capabilities {
474 self.capabilities.clone()
475 }
476
477 async fn health(&self) -> Result<Health, SourceError> {
478 self.ask("health", json!({})).await
479 }
480
481 async fn get_task(&self, id: &NativeId) -> Result<Option<Task>, SourceError> {
482 let result: TaskResult = self
483 .ask("get_task", params(&IdParams { id: id.clone() }))
484 .await?;
485 Ok(result.task)
486 }
487
488 async fn get_project(&self, id: &NativeId) -> Result<Option<Project>, SourceError> {
489 let result: ProjectResult = self
490 .ask("get_project", params(&IdParams { id: id.clone() }))
491 .await?;
492 Ok(result.project)
493 }
494
495 async fn query_tasks(
496 &self,
497 query: &TaskQuery,
498 page: &PageRequest,
499 ) -> Result<Page<Task>, SourceError> {
500 let Some(statuses) = self.statuses_for(&query.statuses) else {
501 return Ok(Page::last(Vec::new()));
502 };
503 self.ask(
504 "query_tasks",
505 params(&TaskQueryParams {
506 query: TaskQuery {
507 statuses,
508 ..query.clone()
509 },
510 page: page.clone(),
511 }),
512 )
513 .await
514 }
515
516 async fn query_projects(
517 &self,
518 query: &ProjectQuery,
519 page: &PageRequest,
520 ) -> Result<Page<Project>, SourceError> {
521 let Some(statuses) = self.statuses_for(&query.statuses) else {
522 return Ok(Page::last(Vec::new()));
523 };
524 self.ask(
525 "query_projects",
526 params(&ProjectQueryParams {
527 query: ProjectQuery {
528 statuses,
529 ..query.clone()
530 },
531 page: page.clone(),
532 }),
533 )
534 .await
535 }
536
537 async fn labels(&self, page: &PageRequest) -> Result<Page<Label>, SourceError> {
538 self.ask("labels", params(&LabelParams { page: page.clone() }))
539 .await
540 }
541
542 async fn task_dependencies(
543 &self,
544 id: &NativeId,
545 direction: Direction,
546 page: &PageRequest,
547 ) -> Result<Page<DependencyEdge>, SourceError> {
548 self.ask(
549 "task_dependencies",
550 params(&DependencyParams {
551 id: id.clone(),
552 direction,
553 page: page.clone(),
554 }),
555 )
556 .await
557 }
558
559 async fn project_dependencies(
560 &self,
561 id: &NativeId,
562 direction: Direction,
563 page: &PageRequest,
564 ) -> Result<Page<DependencyEdge>, SourceError> {
565 self.ask(
566 "project_dependencies",
567 params(&DependencyParams {
568 id: id.clone(),
569 direction,
570 page: page.clone(),
571 }),
572 )
573 .await
574 }
575
576 fn writes(&self) -> WriteSupport {
577 self.writes
578 }
579
580 async fn write_task(&self, write: &ItemWrite<Task>) -> Result<NativeId, SourceError> {
581 self.writable_task(&write.item)?;
582 let result: WriteResult = self
583 .ask(
584 "write_task",
585 params(&TaskWriteParams {
586 write: write.clone(),
587 }),
588 )
589 .await?;
590 Ok(result.id)
591 }
592
593 async fn write_project(&self, write: &ItemWrite<Project>) -> Result<NativeId, SourceError> {
594 self.knows(write.item.status.category)?;
595 let result: WriteResult = self
596 .ask(
597 "write_project",
598 params(&ProjectWriteParams {
599 write: write.clone(),
600 }),
601 )
602 .await?;
603 Ok(result.id)
604 }
605
606 async fn set_task_status(
607 &self,
608 id: &NativeId,
609 category: StatusCategory,
610 ) -> Result<Option<Status>, SourceError> {
611 self.updates("set_task_status")?;
612 self.knows(category)?;
613 let result: StatusResult = self
614 .ask(
615 "set_task_status",
616 params(&StatusParams {
617 id: id.clone(),
618 category,
619 }),
620 )
621 .await?;
622 Ok(result.status)
623 }
624
625 async fn set_delivered_by(
626 &self,
627 id: &NativeId,
628 delivered_by: &[TaskRef],
629 ) -> Result<Option<()>, SourceError> {
630 self.updates("set_delivered_by")?;
631 let result: DeliveredByResult = self
632 .ask(
633 "set_delivered_by",
634 params(&DeliveredByParams {
635 id: id.clone(),
636 delivered_by: delivered_by.to_vec(),
637 }),
638 )
639 .await?;
640 Ok(result.delivered_by.map(|_| ()))
641 }
642
643 async fn set_task_metadata(
644 &self,
645 id: &NativeId,
646 key: &MetadataKey,
647 value: &Value,
648 ) -> Result<Option<Task>, SourceError> {
649 self.metadata_updates(MetadataRecord::Task)?;
650 let result: TaskResult = self
651 .ask("set_task_metadata", metadata_params(id, key, value))
652 .await?;
653 Ok(result.task)
654 }
655
656 async fn set_project_metadata(
657 &self,
658 id: &NativeId,
659 key: &MetadataKey,
660 value: &Value,
661 ) -> Result<Option<Project>, SourceError> {
662 self.metadata_updates(MetadataRecord::Project)?;
663 let result: ProjectResult = self
664 .ask("set_project_metadata", metadata_params(id, key, value))
665 .await?;
666 Ok(result.project)
667 }
668
669 async fn set_document_metadata(
670 &self,
671 id: &NativeId,
672 key: &MetadataKey,
673 value: &Value,
674 ) -> Result<Option<Document>, SourceError> {
675 self.metadata_updates(MetadataRecord::Document)?;
676 let result: DocumentResult = self
677 .ask("set_document_metadata", metadata_params(id, key, value))
678 .await?;
679 Ok(result.document)
680 }
681
682 async fn delete_task(&self, id: &NativeId) -> Result<(), SourceError> {
683 let _: IgnoredResult = self
684 .ask("delete_task", params(&DeleteParams { id: id.clone() }))
685 .await?;
686 Ok(())
687 }
688
689 async fn delete_project(&self, id: &NativeId) -> Result<(), SourceError> {
690 let _: IgnoredResult = self
691 .ask("delete_project", params(&DeleteParams { id: id.clone() }))
692 .await?;
693 Ok(())
694 }
695
696 async fn get_document(&self, id: &NativeId) -> Result<Option<Document>, SourceError> {
697 let result: DocumentResult = self
698 .ask("get_document", params(&IdParams { id: id.clone() }))
699 .await?;
700 Ok(result.document)
701 }
702
703 async fn query_documents(
704 &self,
705 query: &DocumentQuery,
706 page: &PageRequest,
707 ) -> Result<Page<Document>, SourceError> {
708 self.ask(
709 "query_documents",
710 params(&DocumentQueryParams {
711 query: query.clone(),
712 page: page.clone(),
713 }),
714 )
715 .await
716 }
717
718 async fn write_document(&self, write: &ItemWrite<Document>) -> Result<NativeId, SourceError> {
719 let result: WriteResult = self
720 .ask(
721 "write_document",
722 params(&DocumentWriteParams {
723 write: write.clone(),
724 }),
725 )
726 .await?;
727 Ok(result.id)
728 }
729
730 async fn delete_document(&self, id: &NativeId) -> Result<(), SourceError> {
731 let _: IgnoredResult = self
732 .ask("delete_document", params(&DeleteParams { id: id.clone() }))
733 .await?;
734 Ok(())
735 }
736
737 async fn task_comments(
738 &self,
739 task: &NativeId,
740 page: &PageRequest,
741 ) -> Result<Option<Page<Comment>>, SourceError> {
742 let result: CommentsResult = self
743 .ask(
744 "task_comments",
745 params(&CommentsParams {
746 task: task.clone(),
747 page: page.clone(),
748 }),
749 )
750 .await?;
751 Ok(result.page)
752 }
753
754 async fn add_comment(
755 &self,
756 task: &NativeId,
757 comment: &NewComment,
758 ) -> Result<Option<Comment>, SourceError> {
759 let result: CommentResult = self
760 .ask(
761 "add_comment",
762 params(&AddCommentParams {
763 task: task.clone(),
764 comment: comment.clone(),
765 }),
766 )
767 .await?;
768 Ok(result.comment)
769 }
770
771 async fn edit_comment(
772 &self,
773 task: &NativeId,
774 comment: &NativeId,
775 body: &CommentBody,
776 ) -> Result<Option<Comment>, SourceError> {
777 let result: CommentResult = self
778 .ask(
779 "edit_comment",
780 params(&EditCommentParams {
781 task: task.clone(),
782 comment: comment.clone(),
783 body: body.clone(),
784 }),
785 )
786 .await?;
787 Ok(result.comment)
788 }
789
790 async fn delete_comment(
791 &self,
792 task: &NativeId,
793 comment: &NativeId,
794 ) -> Result<Option<NativeId>, SourceError> {
795 let result: DeletedCommentResult = self
796 .ask(
797 "delete_comment",
798 params(&DeleteCommentParams {
799 task: task.clone(),
800 comment: comment.clone(),
801 }),
802 )
803 .await?;
804 Ok(result.deleted)
805 }
806
807 async fn metering(&self) -> Result<Option<Metering>, SourceError> {
808 if !self.meters {
811 return Ok(None);
812 }
813 let result: MeteringResult = self.ask("metering", json!({})).await?;
814 Ok(result.metering)
815 }
816}
817
818#[derive(serde::Deserialize)]
827struct IgnoredResult {}
828
829fn params<T: serde::Serialize>(value: &T) -> Value {
834 serde_json::to_value(value).expect("method parameters are plain data")
835}
836
837fn metadata_params(id: &NativeId, key: &MetadataKey, value: &Value) -> Value {
839 params(&MetadataParams {
840 id: id.clone(),
841 key: key.clone(),
842 value: value.clone(),
843 })
844}