use std::collections::BTreeMap;
use std::num::NonZeroU64;
use std::path::Path;
use std::time::Duration;
use async_trait::async_trait;
use onetaskgraph_plugin_api::{
Capabilities, Comment, CommentBody, DependencyEdge, Direction, Document, DocumentQuery, Health,
ItemWrite, Label, MetadataKey, MetadataRecord, Metering, NativeId, NewComment, Page,
PageRequest, Priority, Project, ProjectQuery, SourceError, SourceName, Status, StatusCategory,
Task, TaskQuery, TaskRef, TaskSource, TaskUpdate, TaskUpdateOutcome, WriteSupport,
unwritable_field, unwritable_metadata,
};
use serde::Deserialize;
use serde_json::{Value, json};
use super::connection::{Connection, Peer};
use super::wire::{
AddCommentParams, CommentResult, CommentsParams, CommentsResult, ContentParams, ContentResult,
DeleteCommentParams, DeleteParams, DeletedCommentResult, DeliveredByParams, DeliveredByResult,
DependencyParams, DocumentDir, DocumentQueryParams, DocumentResult, DocumentWriteParams,
EditCommentParams, EngineIdentity, IdParams, InitializeParams, InitializeResult, LabelParams,
MetadataParams, MeteringResult, PROTOCOL_VERSION, PriorityParams, PriorityResult,
ProjectQueryParams, ProjectResult, ProjectWriteParams, Request, StatusParams, StatusResult,
TaskQueryParams, TaskResult, TaskWriteParams, UpdateParams, UpdateResult, WriteResult,
after_the_first_vocabulary, knows_every_category, spelled, vocabulary,
};
const HANDSHAKE_ID: &str = "0";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RequestDeadline(NonZeroU64);
impl RequestDeadline {
pub const DEFAULT: Self = Self(NonZeroU64::new(30_000).expect("non-zero default"));
#[must_use]
pub const fn from_millis(milliseconds: NonZeroU64) -> Self {
Self(milliseconds)
}
#[must_use]
pub const fn milliseconds(self) -> NonZeroU64 {
self.0
}
fn duration(self) -> Duration {
Duration::from_millis(self.0.get())
}
}
#[derive(Debug, Clone, Copy)]
struct Deadlines {
handshake: RequestDeadline,
requests: RequestDeadline,
}
pub struct SubprocessSource {
kind: &'static str,
capabilities: Capabilities,
writes: WriteSupport,
meters: bool,
knows_every_category: bool,
task_updates: bool,
metadata_updates: bool,
content_updates: bool,
targeted_updates: bool,
ends_commands: bool,
connection: Connection,
}
impl std::fmt::Debug for SubprocessSource {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SubprocessSource")
.field("kind", &self.kind)
.finish_non_exhaustive()
}
}
impl SubprocessSource {
pub fn connect(
program: &str,
args: &[String],
name: &SourceName,
config: &Value,
secrets: BTreeMap<String, String>,
) -> Result<Self, SourceError> {
Self::connect_with_deadline(
program,
args,
name,
config,
secrets,
RequestDeadline::DEFAULT,
)
}
pub fn connect_with_deadline(
program: &str,
args: &[String],
name: &SourceName,
config: &Value,
secrets: BTreeMap<String, String>,
deadline: RequestDeadline,
) -> Result<Self, SourceError> {
Self::connect_with_deadlines(program, args, name, config, secrets, deadline, deadline)
}
pub fn connect_with_deadlines(
program: &str,
args: &[String],
name: &SourceName,
config: &Value,
secrets: BTreeMap<String, String>,
handshake: RequestDeadline,
requests: RequestDeadline,
) -> Result<Self, SourceError> {
Self::connect_bounded(
program,
args,
name,
config,
secrets,
Deadlines {
handshake,
requests,
},
None,
)
}
pub fn connect_from_document(
program: &str,
args: &[String],
name: &SourceName,
config: &Value,
secrets: BTreeMap<String, String>,
deadline: RequestDeadline,
document_dir: Option<&Path>,
) -> Result<Self, SourceError> {
Self::connect_bounded(
program,
args,
name,
config,
secrets,
Deadlines {
handshake: deadline,
requests: deadline,
},
document_dir,
)
}
fn connect_bounded(
program: &str,
args: &[String],
name: &SourceName,
config: &Value,
secrets: BTreeMap<String, String>,
deadlines: Deadlines,
document_dir: Option<&Path>,
) -> Result<Self, SourceError> {
let document_dir = document_dir
.map(|directory| {
DocumentDir::new(directory).map_err(|problem| SourceError::Config {
message: format!(
"source {name}: its settings are measured from the directory holding \
the configuration document that set them, and {problem}; give the \
settings absolute paths, or move the document under a directory \
whose name is valid UTF-8"
),
})
})
.transpose()?;
Self::adopt(
Peer::spawn(
program,
args,
deadlines.handshake.duration(),
deadlines.requests.duration(),
)?,
name,
config,
secrets,
document_dir,
)
}
pub fn over(
to_plugin: impl std::io::Write + Send + 'static,
from_plugin: impl std::io::Read + Send + 'static,
name: &SourceName,
config: &Value,
secrets: BTreeMap<String, String>,
) -> Result<Self, SourceError> {
Self::over_with_request_deadline(
to_plugin,
from_plugin,
name,
config,
secrets,
RequestDeadline::DEFAULT,
)
}
pub fn over_with_request_deadline(
to_plugin: impl std::io::Write + Send + 'static,
from_plugin: impl std::io::Read + Send + 'static,
name: &SourceName,
config: &Value,
secrets: BTreeMap<String, String>,
deadline: RequestDeadline,
) -> Result<Self, SourceError> {
Self::adopt(
Peer::over(to_plugin, from_plugin, deadline.duration()),
name,
config,
secrets,
None,
)
}
fn adopt(
mut peer: Peer,
name: &SourceName,
config: &Value,
secrets: BTreeMap<String, String>,
document_dir: Option<DocumentDir>,
) -> Result<Self, SourceError> {
let result = Self::handshake(&mut peer, name, config, secrets, document_dir);
let InitializeResult {
protocol_version,
kind,
capabilities,
writes,
meters,
statuses,
task_updates,
metadata_updates,
content_updates,
targeted_updates,
ends_commands,
} = match result {
Ok(result) => result,
Err(error) => return Err(with_diagnostics(error, &mut peer)),
};
let kind = kind.into_string();
if protocol_version != Some(PROTOCOL_VERSION) {
return Err(SourceError::Config {
message: match protocol_version {
Some(spoken) => format!(
"the {kind:?} plugin was asked for protocol version \
{PROTOCOL_VERSION} and answered in version {spoken}; the two are \
incompatible and this engine does not guess between them"
),
None => format!(
"the {kind:?} plugin did not say which protocol version it \
answered in; this engine speaks version {PROTOCOL_VERSION} and \
does not guess"
),
},
});
}
Ok(Self {
kind: String::leak(kind),
capabilities,
writes: writes.unwrap_or(WriteSupport::Unsupported),
meters,
knows_every_category: knows_every_category(statuses.as_deref()),
task_updates,
metadata_updates,
content_updates,
targeted_updates,
ends_commands,
connection: Connection::adopt(peer),
})
}
fn handshake(
peer: &mut Peer,
name: &SourceName,
config: &Value,
secrets: BTreeMap<String, String>,
document_dir: Option<DocumentDir>,
) -> Result<InitializeResult, SourceError> {
let params = InitializeParams {
protocol_version: PROTOCOL_VERSION,
engine: EngineIdentity {
name: "onetaskgraph".to_owned(),
version: env!("CARGO_PKG_VERSION").to_owned(),
},
source_name: name.as_str().to_owned(),
config: config.clone(),
secrets,
statuses: Some(vocabulary()),
document_dir,
};
let request = Request {
id: HANDSHAKE_ID.to_owned(),
method: "initialize".to_owned(),
params: serde_json::to_value(¶ms).expect("a handshake is plain data"),
};
let line = peer.exchange(
&serde_json::to_string(&request).expect("a handshake request is plain data"),
)?;
let response: super::wire::Response =
serde_json::from_str(&line).map_err(|error| SourceError::Malformed {
message: format!(
"the plugin's handshake answer is not a response envelope: {error}"
),
})?;
if response.id != HANDSHAKE_ID {
return Err(SourceError::Malformed {
message: format!(
"the plugin answered the handshake with an envelope addressed to {:?} \
rather than to {HANDSHAKE_ID:?}",
response.id
),
});
}
let outcome = response.outcome().ok_or_else(|| SourceError::Malformed {
message: "the plugin's handshake answer carried both a result and an error, or \
neither"
.to_owned(),
})?;
let result = outcome?;
serde_json::from_value(result).map_err(|error| SourceError::Malformed {
message: format!("the plugin's handshake answer is not an initialize result: {error}"),
})
}
fn statuses_for(&self, statuses: &[StatusCategory]) -> Option<Vec<StatusCategory>> {
if self.knows_every_category {
return Some(statuses.to_vec());
}
let known: Vec<StatusCategory> = statuses
.iter()
.copied()
.filter(|category| !after_the_first_vocabulary(*category))
.collect();
(known.len() == statuses.len() || !known.is_empty()).then_some(known)
}
fn knows(&self, category: StatusCategory) -> Result<(), SourceError> {
if self.knows_every_category || !after_the_first_vocabulary(category) {
return Ok(());
}
Err(SourceError::Refused {
message: format!(
"the {:?} plugin's handshake does not list the status category {}, so this \
engine does not hand it one (docs/plugin-protocol.md §3.5); next: upgrade the \
plugin to one whose handshake lists it, or use a category it knows",
self.kind,
spelled(category)
),
})
}
fn updates(&self, what: &str) -> Result<(), SourceError> {
if self.task_updates {
return Ok(());
}
Err(SourceError::Refused {
message: format!(
"the {:?} plugin's handshake does not say it answers the narrow task writes, so \
this engine does not send it {what} (docs/plugin-protocol.md §3.6); next: \
upgrade the plugin to one whose handshake sets task_updates",
self.kind
),
})
}
fn metadata_updates(&self, record: MetadataRecord) -> Result<(), SourceError> {
if self.metadata_updates {
return Ok(());
}
Err(unwritable_metadata(self.kind, record))
}
fn unrendered(&self, operation: &str) -> SourceError {
SourceError::Refused {
message: format!(
"the {:?} plugin is hosted over the stdio plugin protocol, which does not carry \
{operation} (docs/plugin-protocol.md §4), so this engine does not send it; \
nothing was written; next: configure the source in-process under its own \
plugin name rather than through a command",
self.kind
),
}
}
fn writable_task(&self, task: &Task) -> Result<(), SourceError> {
self.knows(task.status.category)?;
if task.priority != Priority::None && !self.capabilities.priority.is_native() {
return Err(unwritable_field(self.kind, "priority"));
}
if !task.delivers.is_empty() || !task.delivered_by.is_empty() {
self.updates("a task carrying delivers or delivered_by")?;
}
Ok(())
}
async fn ask<T: for<'de> Deserialize<'de>>(
&self,
method: &str,
params: Value,
) -> Result<T, SourceError> {
let result = self.connection.call(method, params).await?;
serde_json::from_value(result).map_err(|error| SourceError::Malformed {
message: format!(
"the plugin's answer to {method} is not the shape it promises: {error}"
),
})
}
}
fn with_diagnostics(error: SourceError, peer: &mut Peer) -> SourceError {
let said = peer.said();
if said.is_empty() {
return error;
}
let message = format!("{error}; the plugin wrote: {said}");
match error {
SourceError::RateLimited {
retry_after_seconds,
..
} => SourceError::RateLimited {
retry_after_seconds,
message: Some(message),
},
SourceError::Config { .. } => SourceError::Config { message },
SourceError::Auth { .. } => SourceError::Auth { message },
SourceError::Refused { .. } => SourceError::Refused { message },
SourceError::Malformed { .. } => SourceError::Malformed { message },
SourceError::Unavailable { .. } => SourceError::Unavailable { message },
}
}
#[async_trait]
impl TaskSource for SubprocessSource {
fn kind(&self) -> &'static str {
self.kind
}
fn capabilities(&self) -> Capabilities {
self.capabilities.clone()
}
async fn health(&self) -> Result<Health, SourceError> {
self.ask("health", json!({})).await
}
async fn get_task(&self, id: &NativeId) -> Result<Option<Task>, SourceError> {
let result: TaskResult = self
.ask("get_task", params(&IdParams { id: id.clone() }))
.await?;
Ok(result.task)
}
async fn get_project(&self, id: &NativeId) -> Result<Option<Project>, SourceError> {
let result: ProjectResult = self
.ask("get_project", params(&IdParams { id: id.clone() }))
.await?;
Ok(result.project)
}
async fn query_tasks(
&self,
query: &TaskQuery,
page: &PageRequest,
) -> Result<Page<Task>, SourceError> {
let Some(statuses) = self.statuses_for(&query.statuses) else {
return Ok(Page::last(Vec::new()));
};
self.ask(
"query_tasks",
params(&TaskQueryParams {
query: TaskQuery {
statuses,
..query.clone()
},
page: page.clone(),
}),
)
.await
}
async fn query_projects(
&self,
query: &ProjectQuery,
page: &PageRequest,
) -> Result<Page<Project>, SourceError> {
let Some(statuses) = self.statuses_for(&query.statuses) else {
return Ok(Page::last(Vec::new()));
};
self.ask(
"query_projects",
params(&ProjectQueryParams {
query: ProjectQuery {
statuses,
..query.clone()
},
page: page.clone(),
}),
)
.await
}
async fn labels(&self, page: &PageRequest) -> Result<Page<Label>, SourceError> {
self.ask("labels", params(&LabelParams { page: page.clone() }))
.await
}
async fn task_dependencies(
&self,
id: &NativeId,
direction: Direction,
page: &PageRequest,
) -> Result<Page<DependencyEdge>, SourceError> {
self.ask(
"task_dependencies",
params(&DependencyParams {
id: id.clone(),
direction,
page: page.clone(),
}),
)
.await
}
async fn project_dependencies(
&self,
id: &NativeId,
direction: Direction,
page: &PageRequest,
) -> Result<Page<DependencyEdge>, SourceError> {
self.ask(
"project_dependencies",
params(&DependencyParams {
id: id.clone(),
direction,
page: page.clone(),
}),
)
.await
}
fn writes(&self) -> WriteSupport {
self.writes
}
async fn write_task(&self, write: &ItemWrite<Task>) -> Result<NativeId, SourceError> {
self.writable_task(&write.item)?;
let result: WriteResult = self
.ask(
"write_task",
params(&TaskWriteParams {
write: write.clone(),
}),
)
.await?;
Ok(result.id)
}
async fn write_project(&self, write: &ItemWrite<Project>) -> Result<NativeId, SourceError> {
self.knows(write.item.status.category)?;
let result: WriteResult = self
.ask(
"write_project",
params(&ProjectWriteParams {
write: write.clone(),
}),
)
.await?;
Ok(result.id)
}
async fn set_task_status(
&self,
id: &NativeId,
category: StatusCategory,
) -> Result<Option<Status>, SourceError> {
self.updates("set_task_status")?;
self.knows(category)?;
let result: StatusResult = self
.ask(
"set_task_status",
params(&StatusParams {
id: id.clone(),
category,
}),
)
.await?;
Ok(result.status)
}
async fn set_task_priority(
&self,
id: &NativeId,
priority: Priority,
) -> Result<Option<Priority>, SourceError> {
if !self.capabilities.priority.is_native() {
return Err(unwritable_field(self.kind, "priority"));
}
let result: PriorityResult = self
.ask(
"set_task_priority",
params(&PriorityParams {
id: id.clone(),
priority,
}),
)
.await?;
Ok(result.priority)
}
async fn set_task_content(
&self,
id: &NativeId,
content: &str,
) -> Result<Option<()>, SourceError> {
if !self.content_updates {
return Err(unwritable_field(self.kind, "content"));
}
let result: ContentResult = self
.ask(
"set_task_content",
params(&ContentParams {
id: id.clone(),
content: content.to_owned(),
}),
)
.await?;
match result.id {
None => Ok(None),
Some(written) if &written == id => Ok(Some(())),
Some(written) => Err(SourceError::Malformed {
message: format!(
"the plugin answered set_task_content for {id} with the id {written}, which \
is not the task it was asked to write"
),
}),
}
}
async fn update_task(
&self,
id: &NativeId,
update: &TaskUpdate,
) -> Result<Option<TaskUpdateOutcome>, SourceError> {
if !self.targeted_updates {
return update.rewrite(self, id).await;
}
update.consistent()?;
if let Some(status) = &update.status {
self.knows(status.category)?;
}
if update
.priority
.is_some_and(|priority| priority != Priority::None)
&& !self.capabilities.priority.is_native()
{
return Err(unwritable_field(self.kind, "priority"));
}
if update.delivers.is_some() {
self.updates("an update naming delivers")?;
}
let result: UpdateResult = self
.ask(
"update_task",
params(&UpdateParams {
id: id.clone(),
update: update.clone(),
}),
)
.await?;
let Some(outcome) = result.outcome else {
return Ok(None);
};
if outcome.task.id != *id {
return Err(SourceError::Malformed {
message: format!(
"the plugin answered update_task for {id} with the task {}, which is not the \
task it was asked to update",
outcome.task.id
),
});
}
if let Some(unnamed) = outcome.written.iter().find(|field| !update.names(**field)) {
return Err(SourceError::Malformed {
message: format!(
"the plugin answered update_task for {id} saying it wrote {}, which the \
update did not name",
serde_json::to_value(unnamed)
.ok()
.and_then(|field| field.as_str().map(str::to_owned))
.unwrap_or_else(|| format!("{unnamed:?}"))
),
});
}
Ok(Some(outcome))
}
async fn set_delivered_by(
&self,
id: &NativeId,
delivered_by: &[TaskRef],
) -> Result<Option<()>, SourceError> {
self.updates("set_delivered_by")?;
let result: DeliveredByResult = self
.ask(
"set_delivered_by",
params(&DeliveredByParams {
id: id.clone(),
delivered_by: delivered_by.to_vec(),
}),
)
.await?;
Ok(result.delivered_by.map(|_| ()))
}
async fn set_task_metadata(
&self,
id: &NativeId,
key: &MetadataKey,
value: &Value,
) -> Result<Option<Task>, SourceError> {
self.metadata_updates(MetadataRecord::Task)?;
let result: TaskResult = self
.ask("set_task_metadata", metadata_params(id, key, value)?)
.await?;
Ok(result.task)
}
async fn set_project_metadata(
&self,
id: &NativeId,
key: &MetadataKey,
value: &Value,
) -> Result<Option<Project>, SourceError> {
self.metadata_updates(MetadataRecord::Project)?;
let result: ProjectResult = self
.ask("set_project_metadata", metadata_params(id, key, value)?)
.await?;
Ok(result.project)
}
async fn set_document_metadata(
&self,
id: &NativeId,
key: &MetadataKey,
value: &Value,
) -> Result<Option<Document>, SourceError> {
self.metadata_updates(MetadataRecord::Document)?;
let result: DocumentResult = self
.ask("set_document_metadata", metadata_params(id, key, value)?)
.await?;
Ok(result.document)
}
async fn task_template_answers(
&self,
id: &NativeId,
) -> Result<Option<BTreeMap<String, Value>>, SourceError> {
let _ = id;
Err(self.unrendered("a task's stored template answers, which `task answers` prints and `task render` regenerates over"))
}
async fn document_template_answers(
&self,
id: &NativeId,
) -> Result<Option<BTreeMap<String, Value>>, SourceError> {
let _ = id;
Err(self.unrendered("a document's stored template answers, which `document answers` prints and `document render` regenerates over"))
}
async fn write_task_rendered(
&self,
write: &ItemWrite<Task>,
answers: &BTreeMap<String, Value>,
) -> Result<NativeId, SourceError> {
let _ = (write, answers);
Err(self.unrendered("a task create from a template"))
}
async fn write_document_rendered(
&self,
write: &ItemWrite<Document>,
answers: &BTreeMap<String, Value>,
) -> Result<NativeId, SourceError> {
let _ = (write, answers);
Err(self.unrendered("a document create from a template"))
}
async fn set_task_rendering(
&self,
id: &NativeId,
content: &str,
provenance: &Value,
answers: &BTreeMap<String, Value>,
) -> Result<Option<()>, SourceError> {
let _ = (id, content, provenance, answers);
Err(self.unrendered("a task's regenerate in place"))
}
async fn set_document_rendering(
&self,
id: &NativeId,
content: &str,
provenance: &Value,
answers: &BTreeMap<String, Value>,
) -> Result<Option<()>, SourceError> {
let _ = (id, content, provenance, answers);
Err(self.unrendered("a document's regenerate in place"))
}
async fn delete_task(&self, id: &NativeId) -> Result<(), SourceError> {
let _: IgnoredResult = self
.ask("delete_task", params(&DeleteParams { id: id.clone() }))
.await?;
Ok(())
}
async fn delete_project(&self, id: &NativeId) -> Result<(), SourceError> {
let _: IgnoredResult = self
.ask("delete_project", params(&DeleteParams { id: id.clone() }))
.await?;
Ok(())
}
async fn get_document(&self, id: &NativeId) -> Result<Option<Document>, SourceError> {
let result: DocumentResult = self
.ask("get_document", params(&IdParams { id: id.clone() }))
.await?;
Ok(result.document)
}
async fn query_documents(
&self,
query: &DocumentQuery,
page: &PageRequest,
) -> Result<Page<Document>, SourceError> {
self.ask(
"query_documents",
params(&DocumentQueryParams {
query: query.clone(),
page: page.clone(),
}),
)
.await
}
async fn write_document(&self, write: &ItemWrite<Document>) -> Result<NativeId, SourceError> {
let result: WriteResult = self
.ask(
"write_document",
params(&DocumentWriteParams {
write: write.clone(),
}),
)
.await?;
Ok(result.id)
}
async fn delete_document(&self, id: &NativeId) -> Result<(), SourceError> {
let _: IgnoredResult = self
.ask("delete_document", params(&DeleteParams { id: id.clone() }))
.await?;
Ok(())
}
async fn task_comments(
&self,
task: &NativeId,
page: &PageRequest,
) -> Result<Option<Page<Comment>>, SourceError> {
let result: CommentsResult = self
.ask(
"task_comments",
params(&CommentsParams {
task: task.clone(),
page: page.clone(),
}),
)
.await?;
Ok(result.page)
}
async fn add_comment(
&self,
task: &NativeId,
comment: &NewComment,
) -> Result<Option<Comment>, SourceError> {
let result: CommentResult = self
.ask(
"add_comment",
params(&AddCommentParams {
task: task.clone(),
comment: comment.clone(),
}),
)
.await?;
Ok(result.comment)
}
async fn edit_comment(
&self,
task: &NativeId,
comment: &NativeId,
body: &CommentBody,
) -> Result<Option<Comment>, SourceError> {
let result: CommentResult = self
.ask(
"edit_comment",
params(&EditCommentParams {
task: task.clone(),
comment: comment.clone(),
body: body.clone(),
}),
)
.await?;
Ok(result.comment)
}
async fn delete_comment(
&self,
task: &NativeId,
comment: &NativeId,
) -> Result<Option<NativeId>, SourceError> {
let result: DeletedCommentResult = self
.ask(
"delete_comment",
params(&DeleteCommentParams {
task: task.clone(),
comment: comment.clone(),
}),
)
.await?;
Ok(result.deleted)
}
async fn metering(&self) -> Result<Option<Metering>, SourceError> {
if !self.meters {
return Ok(None);
}
let result: MeteringResult = self.ask("metering", json!({})).await?;
Ok(result.metering)
}
async fn end_command(&self) -> Result<(), SourceError> {
if !self.ends_commands {
return Ok(());
}
let _: IgnoredResult = self.ask("end_command", json!({})).await?;
Ok(())
}
}
#[derive(serde::Deserialize)]
struct IgnoredResult {}
fn params<T: serde::Serialize>(value: &T) -> Value {
serde_json::to_value(value).expect("method parameters are plain data")
}
fn metadata_params(id: &NativeId, key: &MetadataKey, value: &Value) -> Result<Value, SourceError> {
MetadataParams::new(id.clone(), key.clone(), value.clone())
.map(|built| params(&built))
.map_err(|message| SourceError::Refused { message })
}