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, Metering, NativeId, NewComment, Page, PageRequest, Project, ProjectQuery,
19 SourceError, SourceName, Status, StatusCategory, Task, TaskQuery, TaskRef, TaskSource,
20 WriteSupport,
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, MeteringResult, PROTOCOL_VERSION,
31 ProjectQueryParams, ProjectResult, ProjectWriteParams, Request, StatusParams, StatusResult,
32 TaskQueryParams, TaskResult, TaskWriteParams, WriteResult, after_the_first_vocabulary,
33 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 connection: Connection,
99}
100
101impl std::fmt::Debug for SubprocessSource {
102 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
105 f.debug_struct("SubprocessSource")
106 .field("kind", &self.kind)
107 .finish_non_exhaustive()
108 }
109}
110
111impl SubprocessSource {
112 pub fn connect(
121 program: &str,
122 args: &[String],
123 name: &SourceName,
124 config: &Value,
125 secrets: BTreeMap<String, String>,
126 ) -> Result<Self, SourceError> {
127 Self::connect_with_deadline(
128 program,
129 args,
130 name,
131 config,
132 secrets,
133 RequestDeadline::DEFAULT,
134 )
135 }
136
137 pub fn connect_with_deadline(
139 program: &str,
140 args: &[String],
141 name: &SourceName,
142 config: &Value,
143 secrets: BTreeMap<String, String>,
144 deadline: RequestDeadline,
145 ) -> Result<Self, SourceError> {
146 Self::adopt(
147 Peer::spawn(program, args, deadline.duration())?,
148 name,
149 config,
150 secrets,
151 )
152 }
153
154 pub fn over(
169 to_plugin: impl std::io::Write + Send + 'static,
170 from_plugin: impl std::io::Read + Send + 'static,
171 name: &SourceName,
172 config: &Value,
173 secrets: BTreeMap<String, String>,
174 ) -> Result<Self, SourceError> {
175 Self::over_with_request_deadline(
176 to_plugin,
177 from_plugin,
178 name,
179 config,
180 secrets,
181 RequestDeadline::DEFAULT,
182 )
183 }
184
185 pub fn over_with_request_deadline(
191 to_plugin: impl std::io::Write + Send + 'static,
192 from_plugin: impl std::io::Read + Send + 'static,
193 name: &SourceName,
194 config: &Value,
195 secrets: BTreeMap<String, String>,
196 deadline: RequestDeadline,
197 ) -> Result<Self, SourceError> {
198 Self::adopt(
199 Peer::over(to_plugin, from_plugin, deadline.duration()),
200 name,
201 config,
202 secrets,
203 )
204 }
205
206 fn adopt(
208 mut peer: Peer,
209 name: &SourceName,
210 config: &Value,
211 secrets: BTreeMap<String, String>,
212 ) -> Result<Self, SourceError> {
213 let result = Self::handshake(&mut peer, name, config, secrets);
214 let InitializeResult {
215 protocol_version,
216 kind,
217 capabilities,
218 writes,
219 meters,
220 statuses,
221 task_updates,
222 } = match result {
223 Ok(result) => result,
224 Err(error) => return Err(with_diagnostics(error, &mut peer)),
225 };
226 let kind = kind.into_string();
227 if protocol_version != Some(PROTOCOL_VERSION) {
228 return Err(SourceError::Config {
229 message: match protocol_version {
230 Some(spoken) => format!(
231 "the {kind:?} plugin was asked for protocol version \
232 {PROTOCOL_VERSION} and answered in version {spoken}; the two are \
233 incompatible and this engine does not guess between them"
234 ),
235 None => format!(
236 "the {kind:?} plugin did not say which protocol version it \
237 answered in; this engine speaks version {PROTOCOL_VERSION} and \
238 does not guess"
239 ),
240 },
241 });
242 }
243 Ok(Self {
244 kind: String::leak(kind),
245 capabilities,
246 writes: writes.unwrap_or(WriteSupport::Unsupported),
247 meters,
248 knows_every_category: knows_every_category(statuses.as_deref()),
249 task_updates,
250 connection: Connection::adopt(peer),
251 })
252 }
253
254 fn handshake(
256 peer: &mut Peer,
257 name: &SourceName,
258 config: &Value,
259 secrets: BTreeMap<String, String>,
260 ) -> Result<InitializeResult, SourceError> {
261 let params = InitializeParams {
262 protocol_version: PROTOCOL_VERSION,
263 engine: EngineIdentity {
264 name: "onetaskgraph".to_owned(),
265 version: env!("CARGO_PKG_VERSION").to_owned(),
266 },
267 source_name: name.as_str().to_owned(),
268 config: config.clone(),
269 secrets,
270 statuses: Some(vocabulary()),
271 };
272 let request = Request {
273 id: HANDSHAKE_ID.to_owned(),
274 method: "initialize".to_owned(),
275 params: serde_json::to_value(¶ms).expect("a handshake is plain data"),
278 };
279 let line = peer.exchange(
280 &serde_json::to_string(&request).expect("a handshake request is plain data"),
281 )?;
282 let response: super::wire::Response =
283 serde_json::from_str(&line).map_err(|error| SourceError::Malformed {
284 message: format!(
285 "the plugin's handshake answer is not a response envelope: {error}"
286 ),
287 })?;
288 if response.id != HANDSHAKE_ID {
293 return Err(SourceError::Malformed {
294 message: format!(
295 "the plugin answered the handshake with an envelope addressed to {:?} \
296 rather than to {HANDSHAKE_ID:?}",
297 response.id
298 ),
299 });
300 }
301 let outcome = response.outcome().ok_or_else(|| SourceError::Malformed {
302 message: "the plugin's handshake answer carried both a result and an error, or \
303 neither"
304 .to_owned(),
305 })?;
306 let result = outcome?;
307 serde_json::from_value(result).map_err(|error| SourceError::Malformed {
308 message: format!("the plugin's handshake answer is not an initialize result: {error}"),
309 })
310 }
311
312 fn statuses_for(&self, statuses: &[StatusCategory]) -> Option<Vec<StatusCategory>> {
319 if self.knows_every_category {
320 return Some(statuses.to_vec());
321 }
322 let known: Vec<StatusCategory> = statuses
323 .iter()
324 .copied()
325 .filter(|category| !after_the_first_vocabulary(*category))
326 .collect();
327 (known.len() == statuses.len() || !known.is_empty()).then_some(known)
328 }
329
330 fn knows(&self, category: StatusCategory) -> Result<(), SourceError> {
333 if self.knows_every_category || !after_the_first_vocabulary(category) {
334 return Ok(());
335 }
336 Err(SourceError::Refused {
337 message: format!(
338 "the {:?} plugin's handshake does not list the status category {}, so this \
339 engine does not hand it one (docs/plugin-protocol.md §3.5); next: upgrade the \
340 plugin to one whose handshake lists it, or use a category it knows",
341 self.kind,
342 spelled(category)
343 ),
344 })
345 }
346
347 fn updates(&self, what: &str) -> Result<(), SourceError> {
349 if self.task_updates {
350 return Ok(());
351 }
352 Err(SourceError::Refused {
353 message: format!(
354 "the {:?} plugin's handshake does not say it answers the narrow task writes, so \
355 this engine does not send it {what} (docs/plugin-protocol.md §3.6); next: \
356 upgrade the plugin to one whose handshake sets task_updates",
357 self.kind
358 ),
359 })
360 }
361
362 fn writable_task(&self, task: &Task) -> Result<(), SourceError> {
364 self.knows(task.status.category)?;
365 if !task.delivers.is_empty() || !task.delivered_by.is_empty() {
366 self.updates("a task carrying delivers or delivered_by")?;
367 }
368 Ok(())
369 }
370
371 async fn ask<T: for<'de> Deserialize<'de>>(
373 &self,
374 method: &str,
375 params: Value,
376 ) -> Result<T, SourceError> {
377 let result = self.connection.call(method, params).await?;
378 serde_json::from_value(result).map_err(|error| SourceError::Malformed {
379 message: format!(
380 "the plugin's answer to {method} is not the shape it promises: {error}"
381 ),
382 })
383 }
384}
385
386fn with_diagnostics(error: SourceError, peer: &mut Peer) -> SourceError {
391 let said = peer.said();
392 if said.is_empty() {
393 return error;
394 }
395 let message = format!("{error}; the plugin wrote: {said}");
396 match error {
397 SourceError::RateLimited {
400 retry_after_seconds,
401 ..
402 } => SourceError::RateLimited {
403 retry_after_seconds,
404 message: Some(message),
405 },
406 SourceError::Config { .. } => SourceError::Config { message },
407 SourceError::Auth { .. } => SourceError::Auth { message },
408 SourceError::Refused { .. } => SourceError::Refused { message },
409 SourceError::Malformed { .. } => SourceError::Malformed { message },
410 SourceError::Unavailable { .. } => SourceError::Unavailable { message },
411 }
412}
413
414#[async_trait]
415impl TaskSource for SubprocessSource {
416 fn kind(&self) -> &'static str {
417 self.kind
418 }
419
420 fn capabilities(&self) -> Capabilities {
421 self.capabilities.clone()
422 }
423
424 async fn health(&self) -> Result<Health, SourceError> {
425 self.ask("health", json!({})).await
426 }
427
428 async fn get_task(&self, id: &NativeId) -> Result<Option<Task>, SourceError> {
429 let result: TaskResult = self
430 .ask("get_task", params(&IdParams { id: id.clone() }))
431 .await?;
432 Ok(result.task)
433 }
434
435 async fn get_project(&self, id: &NativeId) -> Result<Option<Project>, SourceError> {
436 let result: ProjectResult = self
437 .ask("get_project", params(&IdParams { id: id.clone() }))
438 .await?;
439 Ok(result.project)
440 }
441
442 async fn query_tasks(
443 &self,
444 query: &TaskQuery,
445 page: &PageRequest,
446 ) -> Result<Page<Task>, SourceError> {
447 let Some(statuses) = self.statuses_for(&query.statuses) else {
448 return Ok(Page::last(Vec::new()));
449 };
450 self.ask(
451 "query_tasks",
452 params(&TaskQueryParams {
453 query: TaskQuery {
454 statuses,
455 ..query.clone()
456 },
457 page: page.clone(),
458 }),
459 )
460 .await
461 }
462
463 async fn query_projects(
464 &self,
465 query: &ProjectQuery,
466 page: &PageRequest,
467 ) -> Result<Page<Project>, SourceError> {
468 let Some(statuses) = self.statuses_for(&query.statuses) else {
469 return Ok(Page::last(Vec::new()));
470 };
471 self.ask(
472 "query_projects",
473 params(&ProjectQueryParams {
474 query: ProjectQuery {
475 statuses,
476 ..query.clone()
477 },
478 page: page.clone(),
479 }),
480 )
481 .await
482 }
483
484 async fn labels(&self, page: &PageRequest) -> Result<Page<Label>, SourceError> {
485 self.ask("labels", params(&LabelParams { page: page.clone() }))
486 .await
487 }
488
489 async fn task_dependencies(
490 &self,
491 id: &NativeId,
492 direction: Direction,
493 page: &PageRequest,
494 ) -> Result<Page<DependencyEdge>, SourceError> {
495 self.ask(
496 "task_dependencies",
497 params(&DependencyParams {
498 id: id.clone(),
499 direction,
500 page: page.clone(),
501 }),
502 )
503 .await
504 }
505
506 async fn project_dependencies(
507 &self,
508 id: &NativeId,
509 direction: Direction,
510 page: &PageRequest,
511 ) -> Result<Page<DependencyEdge>, SourceError> {
512 self.ask(
513 "project_dependencies",
514 params(&DependencyParams {
515 id: id.clone(),
516 direction,
517 page: page.clone(),
518 }),
519 )
520 .await
521 }
522
523 fn writes(&self) -> WriteSupport {
524 self.writes
525 }
526
527 async fn write_task(&self, write: &ItemWrite<Task>) -> Result<NativeId, SourceError> {
528 self.writable_task(&write.item)?;
529 let result: WriteResult = self
530 .ask(
531 "write_task",
532 params(&TaskWriteParams {
533 write: write.clone(),
534 }),
535 )
536 .await?;
537 Ok(result.id)
538 }
539
540 async fn write_project(&self, write: &ItemWrite<Project>) -> Result<NativeId, SourceError> {
541 self.knows(write.item.status.category)?;
542 let result: WriteResult = self
543 .ask(
544 "write_project",
545 params(&ProjectWriteParams {
546 write: write.clone(),
547 }),
548 )
549 .await?;
550 Ok(result.id)
551 }
552
553 async fn set_task_status(
554 &self,
555 id: &NativeId,
556 category: StatusCategory,
557 ) -> Result<Option<Status>, SourceError> {
558 self.updates("set_task_status")?;
559 self.knows(category)?;
560 let result: StatusResult = self
561 .ask(
562 "set_task_status",
563 params(&StatusParams {
564 id: id.clone(),
565 category,
566 }),
567 )
568 .await?;
569 Ok(result.status)
570 }
571
572 async fn set_delivered_by(
573 &self,
574 id: &NativeId,
575 delivered_by: &[TaskRef],
576 ) -> Result<Option<()>, SourceError> {
577 self.updates("set_delivered_by")?;
578 let result: DeliveredByResult = self
579 .ask(
580 "set_delivered_by",
581 params(&DeliveredByParams {
582 id: id.clone(),
583 delivered_by: delivered_by.to_vec(),
584 }),
585 )
586 .await?;
587 Ok(result.delivered_by.map(|_| ()))
588 }
589
590 async fn delete_task(&self, id: &NativeId) -> Result<(), SourceError> {
591 let _: IgnoredResult = self
592 .ask("delete_task", params(&DeleteParams { id: id.clone() }))
593 .await?;
594 Ok(())
595 }
596
597 async fn delete_project(&self, id: &NativeId) -> Result<(), SourceError> {
598 let _: IgnoredResult = self
599 .ask("delete_project", params(&DeleteParams { id: id.clone() }))
600 .await?;
601 Ok(())
602 }
603
604 async fn get_document(&self, id: &NativeId) -> Result<Option<Document>, SourceError> {
605 let result: DocumentResult = self
606 .ask("get_document", params(&IdParams { id: id.clone() }))
607 .await?;
608 Ok(result.document)
609 }
610
611 async fn query_documents(
612 &self,
613 query: &DocumentQuery,
614 page: &PageRequest,
615 ) -> Result<Page<Document>, SourceError> {
616 self.ask(
617 "query_documents",
618 params(&DocumentQueryParams {
619 query: query.clone(),
620 page: page.clone(),
621 }),
622 )
623 .await
624 }
625
626 async fn write_document(&self, write: &ItemWrite<Document>) -> Result<NativeId, SourceError> {
627 let result: WriteResult = self
628 .ask(
629 "write_document",
630 params(&DocumentWriteParams {
631 write: write.clone(),
632 }),
633 )
634 .await?;
635 Ok(result.id)
636 }
637
638 async fn delete_document(&self, id: &NativeId) -> Result<(), SourceError> {
639 let _: IgnoredResult = self
640 .ask("delete_document", params(&DeleteParams { id: id.clone() }))
641 .await?;
642 Ok(())
643 }
644
645 async fn task_comments(
646 &self,
647 task: &NativeId,
648 page: &PageRequest,
649 ) -> Result<Option<Page<Comment>>, SourceError> {
650 let result: CommentsResult = self
651 .ask(
652 "task_comments",
653 params(&CommentsParams {
654 task: task.clone(),
655 page: page.clone(),
656 }),
657 )
658 .await?;
659 Ok(result.page)
660 }
661
662 async fn add_comment(
663 &self,
664 task: &NativeId,
665 comment: &NewComment,
666 ) -> Result<Option<Comment>, SourceError> {
667 let result: CommentResult = self
668 .ask(
669 "add_comment",
670 params(&AddCommentParams {
671 task: task.clone(),
672 comment: comment.clone(),
673 }),
674 )
675 .await?;
676 Ok(result.comment)
677 }
678
679 async fn edit_comment(
680 &self,
681 task: &NativeId,
682 comment: &NativeId,
683 body: &CommentBody,
684 ) -> Result<Option<Comment>, SourceError> {
685 let result: CommentResult = self
686 .ask(
687 "edit_comment",
688 params(&EditCommentParams {
689 task: task.clone(),
690 comment: comment.clone(),
691 body: body.clone(),
692 }),
693 )
694 .await?;
695 Ok(result.comment)
696 }
697
698 async fn delete_comment(
699 &self,
700 task: &NativeId,
701 comment: &NativeId,
702 ) -> Result<Option<NativeId>, SourceError> {
703 let result: DeletedCommentResult = self
704 .ask(
705 "delete_comment",
706 params(&DeleteCommentParams {
707 task: task.clone(),
708 comment: comment.clone(),
709 }),
710 )
711 .await?;
712 Ok(result.deleted)
713 }
714
715 async fn metering(&self) -> Result<Option<Metering>, SourceError> {
716 if !self.meters {
719 return Ok(None);
720 }
721 let result: MeteringResult = self.ask("metering", json!({})).await?;
722 Ok(result.metering)
723 }
724}
725
726#[derive(serde::Deserialize)]
735struct IgnoredResult {}
736
737fn params<T: serde::Serialize>(value: &T) -> Value {
742 serde_json::to_value(value).expect("method parameters are plain data")
743}