1use std::collections::BTreeMap;
12use std::num::NonZeroU64;
13use std::time::Duration;
14
15use async_trait::async_trait;
16use onetaskgraph_plugin_api::{
17 Capabilities, Comment, CommentBody, DependencyEdge, Direction, Document, DocumentQuery, Health,
18 ItemWrite, Label, MetadataKey, MetadataRecord, Metering, NativeId, NewComment, Page,
19 PageRequest, Project, ProjectQuery, SourceError, SourceName, Status, StatusCategory, Task,
20 TaskQuery, TaskRef, TaskSource, WriteSupport, unwritable_metadata,
21};
22use serde::Deserialize;
23use serde_json::{Value, json};
24
25use super::connection::{Connection, Peer};
26use super::wire::{
27 AddCommentParams, CommentResult, CommentsParams, CommentsResult, DeleteCommentParams,
28 DeleteParams, DeletedCommentResult, DeliveredByParams, DeliveredByResult, DependencyParams,
29 DocumentQueryParams, DocumentResult, DocumentWriteParams, EditCommentParams, EngineIdentity,
30 IdParams, InitializeParams, InitializeResult, LabelParams, MetadataParams, MeteringResult,
31 PROTOCOL_VERSION, ProjectQueryParams, ProjectResult, ProjectWriteParams, Request, StatusParams,
32 StatusResult, TaskQueryParams, TaskResult, TaskWriteParams, WriteResult,
33 after_the_first_vocabulary, knows_every_category, spelled, vocabulary,
34};
35
36const HANDSHAKE_ID: &str = "0";
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub struct RequestDeadline(NonZeroU64);
44
45impl RequestDeadline {
46 pub const DEFAULT: Self = Self(NonZeroU64::new(30_000).expect("non-zero default"));
48
49 #[must_use]
51 pub const fn from_millis(milliseconds: NonZeroU64) -> Self {
52 Self(milliseconds)
53 }
54
55 #[must_use]
57 pub const fn milliseconds(self) -> NonZeroU64 {
58 self.0
59 }
60
61 fn duration(self) -> Duration {
62 Duration::from_millis(self.0.get())
63 }
64}
65
66pub struct SubprocessSource {
68 kind: &'static str,
76 capabilities: Capabilities,
78 writes: WriteSupport,
84 meters: bool,
89 knows_every_category: bool,
94 task_updates: bool,
97 metadata_updates: bool,
100 connection: Connection,
102}
103
104impl std::fmt::Debug for SubprocessSource {
105 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
108 f.debug_struct("SubprocessSource")
109 .field("kind", &self.kind)
110 .finish_non_exhaustive()
111 }
112}
113
114impl SubprocessSource {
115 pub fn connect(
124 program: &str,
125 args: &[String],
126 name: &SourceName,
127 config: &Value,
128 secrets: BTreeMap<String, String>,
129 ) -> Result<Self, SourceError> {
130 Self::connect_with_deadline(
131 program,
132 args,
133 name,
134 config,
135 secrets,
136 RequestDeadline::DEFAULT,
137 )
138 }
139
140 pub fn connect_with_deadline(
142 program: &str,
143 args: &[String],
144 name: &SourceName,
145 config: &Value,
146 secrets: BTreeMap<String, String>,
147 deadline: RequestDeadline,
148 ) -> Result<Self, SourceError> {
149 Self::adopt(
150 Peer::spawn(program, args, deadline.duration())?,
151 name,
152 config,
153 secrets,
154 )
155 }
156
157 pub fn over(
172 to_plugin: impl std::io::Write + Send + 'static,
173 from_plugin: impl std::io::Read + Send + 'static,
174 name: &SourceName,
175 config: &Value,
176 secrets: BTreeMap<String, String>,
177 ) -> Result<Self, SourceError> {
178 Self::over_with_request_deadline(
179 to_plugin,
180 from_plugin,
181 name,
182 config,
183 secrets,
184 RequestDeadline::DEFAULT,
185 )
186 }
187
188 pub fn over_with_request_deadline(
194 to_plugin: impl std::io::Write + Send + 'static,
195 from_plugin: impl std::io::Read + Send + 'static,
196 name: &SourceName,
197 config: &Value,
198 secrets: BTreeMap<String, String>,
199 deadline: RequestDeadline,
200 ) -> Result<Self, SourceError> {
201 Self::adopt(
202 Peer::over(to_plugin, from_plugin, deadline.duration()),
203 name,
204 config,
205 secrets,
206 )
207 }
208
209 fn adopt(
211 mut peer: Peer,
212 name: &SourceName,
213 config: &Value,
214 secrets: BTreeMap<String, String>,
215 ) -> Result<Self, SourceError> {
216 let result = Self::handshake(&mut peer, name, config, secrets);
217 let InitializeResult {
218 protocol_version,
219 kind,
220 capabilities,
221 writes,
222 meters,
223 statuses,
224 task_updates,
225 metadata_updates,
226 } = match result {
227 Ok(result) => result,
228 Err(error) => return Err(with_diagnostics(error, &mut peer)),
229 };
230 let kind = kind.into_string();
231 if protocol_version != Some(PROTOCOL_VERSION) {
232 return Err(SourceError::Config {
233 message: match protocol_version {
234 Some(spoken) => format!(
235 "the {kind:?} plugin was asked for protocol version \
236 {PROTOCOL_VERSION} and answered in version {spoken}; the two are \
237 incompatible and this engine does not guess between them"
238 ),
239 None => format!(
240 "the {kind:?} plugin did not say which protocol version it \
241 answered in; this engine speaks version {PROTOCOL_VERSION} and \
242 does not guess"
243 ),
244 },
245 });
246 }
247 Ok(Self {
248 kind: String::leak(kind),
249 capabilities,
250 writes: writes.unwrap_or(WriteSupport::Unsupported),
251 meters,
252 knows_every_category: knows_every_category(statuses.as_deref()),
253 task_updates,
254 metadata_updates,
255 connection: Connection::adopt(peer),
256 })
257 }
258
259 fn handshake(
261 peer: &mut Peer,
262 name: &SourceName,
263 config: &Value,
264 secrets: BTreeMap<String, String>,
265 ) -> Result<InitializeResult, SourceError> {
266 let params = InitializeParams {
267 protocol_version: PROTOCOL_VERSION,
268 engine: EngineIdentity {
269 name: "onetaskgraph".to_owned(),
270 version: env!("CARGO_PKG_VERSION").to_owned(),
271 },
272 source_name: name.as_str().to_owned(),
273 config: config.clone(),
274 secrets,
275 statuses: Some(vocabulary()),
276 };
277 let request = Request {
278 id: HANDSHAKE_ID.to_owned(),
279 method: "initialize".to_owned(),
280 params: serde_json::to_value(¶ms).expect("a handshake is plain data"),
283 };
284 let line = peer.exchange(
285 &serde_json::to_string(&request).expect("a handshake request is plain data"),
286 )?;
287 let response: super::wire::Response =
288 serde_json::from_str(&line).map_err(|error| SourceError::Malformed {
289 message: format!(
290 "the plugin's handshake answer is not a response envelope: {error}"
291 ),
292 })?;
293 if response.id != HANDSHAKE_ID {
298 return Err(SourceError::Malformed {
299 message: format!(
300 "the plugin answered the handshake with an envelope addressed to {:?} \
301 rather than to {HANDSHAKE_ID:?}",
302 response.id
303 ),
304 });
305 }
306 let outcome = response.outcome().ok_or_else(|| SourceError::Malformed {
307 message: "the plugin's handshake answer carried both a result and an error, or \
308 neither"
309 .to_owned(),
310 })?;
311 let result = outcome?;
312 serde_json::from_value(result).map_err(|error| SourceError::Malformed {
313 message: format!("the plugin's handshake answer is not an initialize result: {error}"),
314 })
315 }
316
317 fn statuses_for(&self, statuses: &[StatusCategory]) -> Option<Vec<StatusCategory>> {
324 if self.knows_every_category {
325 return Some(statuses.to_vec());
326 }
327 let known: Vec<StatusCategory> = statuses
328 .iter()
329 .copied()
330 .filter(|category| !after_the_first_vocabulary(*category))
331 .collect();
332 (known.len() == statuses.len() || !known.is_empty()).then_some(known)
333 }
334
335 fn knows(&self, category: StatusCategory) -> Result<(), SourceError> {
338 if self.knows_every_category || !after_the_first_vocabulary(category) {
339 return Ok(());
340 }
341 Err(SourceError::Refused {
342 message: format!(
343 "the {:?} plugin's handshake does not list the status category {}, so this \
344 engine does not hand it one (docs/plugin-protocol.md §3.5); next: upgrade the \
345 plugin to one whose handshake lists it, or use a category it knows",
346 self.kind,
347 spelled(category)
348 ),
349 })
350 }
351
352 fn updates(&self, what: &str) -> Result<(), SourceError> {
354 if self.task_updates {
355 return Ok(());
356 }
357 Err(SourceError::Refused {
358 message: format!(
359 "the {:?} plugin's handshake does not say it answers the narrow task writes, so \
360 this engine does not send it {what} (docs/plugin-protocol.md §3.6); next: \
361 upgrade the plugin to one whose handshake sets task_updates",
362 self.kind
363 ),
364 })
365 }
366
367 fn metadata_updates(&self, record: MetadataRecord) -> Result<(), SourceError> {
370 if self.metadata_updates {
371 return Ok(());
372 }
373 Err(unwritable_metadata(self.kind, record))
374 }
375
376 fn writable_task(&self, task: &Task) -> Result<(), SourceError> {
378 self.knows(task.status.category)?;
379 if !task.delivers.is_empty() || !task.delivered_by.is_empty() {
380 self.updates("a task carrying delivers or delivered_by")?;
381 }
382 Ok(())
383 }
384
385 async fn ask<T: for<'de> Deserialize<'de>>(
387 &self,
388 method: &str,
389 params: Value,
390 ) -> Result<T, SourceError> {
391 let result = self.connection.call(method, params).await?;
392 serde_json::from_value(result).map_err(|error| SourceError::Malformed {
393 message: format!(
394 "the plugin's answer to {method} is not the shape it promises: {error}"
395 ),
396 })
397 }
398}
399
400fn with_diagnostics(error: SourceError, peer: &mut Peer) -> SourceError {
405 let said = peer.said();
406 if said.is_empty() {
407 return error;
408 }
409 let message = format!("{error}; the plugin wrote: {said}");
410 match error {
411 SourceError::RateLimited {
414 retry_after_seconds,
415 ..
416 } => SourceError::RateLimited {
417 retry_after_seconds,
418 message: Some(message),
419 },
420 SourceError::Config { .. } => SourceError::Config { message },
421 SourceError::Auth { .. } => SourceError::Auth { message },
422 SourceError::Refused { .. } => SourceError::Refused { message },
423 SourceError::Malformed { .. } => SourceError::Malformed { message },
424 SourceError::Unavailable { .. } => SourceError::Unavailable { message },
425 }
426}
427
428#[async_trait]
429impl TaskSource for SubprocessSource {
430 fn kind(&self) -> &'static str {
431 self.kind
432 }
433
434 fn capabilities(&self) -> Capabilities {
435 self.capabilities.clone()
436 }
437
438 async fn health(&self) -> Result<Health, SourceError> {
439 self.ask("health", json!({})).await
440 }
441
442 async fn get_task(&self, id: &NativeId) -> Result<Option<Task>, SourceError> {
443 let result: TaskResult = self
444 .ask("get_task", params(&IdParams { id: id.clone() }))
445 .await?;
446 Ok(result.task)
447 }
448
449 async fn get_project(&self, id: &NativeId) -> Result<Option<Project>, SourceError> {
450 let result: ProjectResult = self
451 .ask("get_project", params(&IdParams { id: id.clone() }))
452 .await?;
453 Ok(result.project)
454 }
455
456 async fn query_tasks(
457 &self,
458 query: &TaskQuery,
459 page: &PageRequest,
460 ) -> Result<Page<Task>, SourceError> {
461 let Some(statuses) = self.statuses_for(&query.statuses) else {
462 return Ok(Page::last(Vec::new()));
463 };
464 self.ask(
465 "query_tasks",
466 params(&TaskQueryParams {
467 query: TaskQuery {
468 statuses,
469 ..query.clone()
470 },
471 page: page.clone(),
472 }),
473 )
474 .await
475 }
476
477 async fn query_projects(
478 &self,
479 query: &ProjectQuery,
480 page: &PageRequest,
481 ) -> Result<Page<Project>, SourceError> {
482 let Some(statuses) = self.statuses_for(&query.statuses) else {
483 return Ok(Page::last(Vec::new()));
484 };
485 self.ask(
486 "query_projects",
487 params(&ProjectQueryParams {
488 query: ProjectQuery {
489 statuses,
490 ..query.clone()
491 },
492 page: page.clone(),
493 }),
494 )
495 .await
496 }
497
498 async fn labels(&self, page: &PageRequest) -> Result<Page<Label>, SourceError> {
499 self.ask("labels", params(&LabelParams { page: page.clone() }))
500 .await
501 }
502
503 async fn task_dependencies(
504 &self,
505 id: &NativeId,
506 direction: Direction,
507 page: &PageRequest,
508 ) -> Result<Page<DependencyEdge>, SourceError> {
509 self.ask(
510 "task_dependencies",
511 params(&DependencyParams {
512 id: id.clone(),
513 direction,
514 page: page.clone(),
515 }),
516 )
517 .await
518 }
519
520 async fn project_dependencies(
521 &self,
522 id: &NativeId,
523 direction: Direction,
524 page: &PageRequest,
525 ) -> Result<Page<DependencyEdge>, SourceError> {
526 self.ask(
527 "project_dependencies",
528 params(&DependencyParams {
529 id: id.clone(),
530 direction,
531 page: page.clone(),
532 }),
533 )
534 .await
535 }
536
537 fn writes(&self) -> WriteSupport {
538 self.writes
539 }
540
541 async fn write_task(&self, write: &ItemWrite<Task>) -> Result<NativeId, SourceError> {
542 self.writable_task(&write.item)?;
543 let result: WriteResult = self
544 .ask(
545 "write_task",
546 params(&TaskWriteParams {
547 write: write.clone(),
548 }),
549 )
550 .await?;
551 Ok(result.id)
552 }
553
554 async fn write_project(&self, write: &ItemWrite<Project>) -> Result<NativeId, SourceError> {
555 self.knows(write.item.status.category)?;
556 let result: WriteResult = self
557 .ask(
558 "write_project",
559 params(&ProjectWriteParams {
560 write: write.clone(),
561 }),
562 )
563 .await?;
564 Ok(result.id)
565 }
566
567 async fn set_task_status(
568 &self,
569 id: &NativeId,
570 category: StatusCategory,
571 ) -> Result<Option<Status>, SourceError> {
572 self.updates("set_task_status")?;
573 self.knows(category)?;
574 let result: StatusResult = self
575 .ask(
576 "set_task_status",
577 params(&StatusParams {
578 id: id.clone(),
579 category,
580 }),
581 )
582 .await?;
583 Ok(result.status)
584 }
585
586 async fn set_delivered_by(
587 &self,
588 id: &NativeId,
589 delivered_by: &[TaskRef],
590 ) -> Result<Option<()>, SourceError> {
591 self.updates("set_delivered_by")?;
592 let result: DeliveredByResult = self
593 .ask(
594 "set_delivered_by",
595 params(&DeliveredByParams {
596 id: id.clone(),
597 delivered_by: delivered_by.to_vec(),
598 }),
599 )
600 .await?;
601 Ok(result.delivered_by.map(|_| ()))
602 }
603
604 async fn set_task_metadata(
605 &self,
606 id: &NativeId,
607 key: &MetadataKey,
608 value: &Value,
609 ) -> Result<Option<Task>, SourceError> {
610 self.metadata_updates(MetadataRecord::Task)?;
611 let result: TaskResult = self
612 .ask("set_task_metadata", metadata_params(id, key, value))
613 .await?;
614 Ok(result.task)
615 }
616
617 async fn set_project_metadata(
618 &self,
619 id: &NativeId,
620 key: &MetadataKey,
621 value: &Value,
622 ) -> Result<Option<Project>, SourceError> {
623 self.metadata_updates(MetadataRecord::Project)?;
624 let result: ProjectResult = self
625 .ask("set_project_metadata", metadata_params(id, key, value))
626 .await?;
627 Ok(result.project)
628 }
629
630 async fn set_document_metadata(
631 &self,
632 id: &NativeId,
633 key: &MetadataKey,
634 value: &Value,
635 ) -> Result<Option<Document>, SourceError> {
636 self.metadata_updates(MetadataRecord::Document)?;
637 let result: DocumentResult = self
638 .ask("set_document_metadata", metadata_params(id, key, value))
639 .await?;
640 Ok(result.document)
641 }
642
643 async fn delete_task(&self, id: &NativeId) -> Result<(), SourceError> {
644 let _: IgnoredResult = self
645 .ask("delete_task", params(&DeleteParams { id: id.clone() }))
646 .await?;
647 Ok(())
648 }
649
650 async fn delete_project(&self, id: &NativeId) -> Result<(), SourceError> {
651 let _: IgnoredResult = self
652 .ask("delete_project", params(&DeleteParams { id: id.clone() }))
653 .await?;
654 Ok(())
655 }
656
657 async fn get_document(&self, id: &NativeId) -> Result<Option<Document>, SourceError> {
658 let result: DocumentResult = self
659 .ask("get_document", params(&IdParams { id: id.clone() }))
660 .await?;
661 Ok(result.document)
662 }
663
664 async fn query_documents(
665 &self,
666 query: &DocumentQuery,
667 page: &PageRequest,
668 ) -> Result<Page<Document>, SourceError> {
669 self.ask(
670 "query_documents",
671 params(&DocumentQueryParams {
672 query: query.clone(),
673 page: page.clone(),
674 }),
675 )
676 .await
677 }
678
679 async fn write_document(&self, write: &ItemWrite<Document>) -> Result<NativeId, SourceError> {
680 let result: WriteResult = self
681 .ask(
682 "write_document",
683 params(&DocumentWriteParams {
684 write: write.clone(),
685 }),
686 )
687 .await?;
688 Ok(result.id)
689 }
690
691 async fn delete_document(&self, id: &NativeId) -> Result<(), SourceError> {
692 let _: IgnoredResult = self
693 .ask("delete_document", params(&DeleteParams { id: id.clone() }))
694 .await?;
695 Ok(())
696 }
697
698 async fn task_comments(
699 &self,
700 task: &NativeId,
701 page: &PageRequest,
702 ) -> Result<Option<Page<Comment>>, SourceError> {
703 let result: CommentsResult = self
704 .ask(
705 "task_comments",
706 params(&CommentsParams {
707 task: task.clone(),
708 page: page.clone(),
709 }),
710 )
711 .await?;
712 Ok(result.page)
713 }
714
715 async fn add_comment(
716 &self,
717 task: &NativeId,
718 comment: &NewComment,
719 ) -> Result<Option<Comment>, SourceError> {
720 let result: CommentResult = self
721 .ask(
722 "add_comment",
723 params(&AddCommentParams {
724 task: task.clone(),
725 comment: comment.clone(),
726 }),
727 )
728 .await?;
729 Ok(result.comment)
730 }
731
732 async fn edit_comment(
733 &self,
734 task: &NativeId,
735 comment: &NativeId,
736 body: &CommentBody,
737 ) -> Result<Option<Comment>, SourceError> {
738 let result: CommentResult = self
739 .ask(
740 "edit_comment",
741 params(&EditCommentParams {
742 task: task.clone(),
743 comment: comment.clone(),
744 body: body.clone(),
745 }),
746 )
747 .await?;
748 Ok(result.comment)
749 }
750
751 async fn delete_comment(
752 &self,
753 task: &NativeId,
754 comment: &NativeId,
755 ) -> Result<Option<NativeId>, SourceError> {
756 let result: DeletedCommentResult = self
757 .ask(
758 "delete_comment",
759 params(&DeleteCommentParams {
760 task: task.clone(),
761 comment: comment.clone(),
762 }),
763 )
764 .await?;
765 Ok(result.deleted)
766 }
767
768 async fn metering(&self) -> Result<Option<Metering>, SourceError> {
769 if !self.meters {
772 return Ok(None);
773 }
774 let result: MeteringResult = self.ask("metering", json!({})).await?;
775 Ok(result.metering)
776 }
777}
778
779#[derive(serde::Deserialize)]
788struct IgnoredResult {}
789
790fn params<T: serde::Serialize>(value: &T) -> Value {
795 serde_json::to_value(value).expect("method parameters are plain data")
796}
797
798fn metadata_params(id: &NativeId, key: &MetadataKey, value: &Value) -> Value {
800 params(&MetadataParams {
801 id: id.clone(),
802 key: key.clone(),
803 value: value.clone(),
804 })
805}