1use std::collections::BTreeMap;
12use std::num::NonZeroU64;
13use std::time::Duration;
14
15use async_trait::async_trait;
16use onetaskgraph_plugin_api::{
17 Capabilities, DependencyEdge, Direction, Health, ItemWrite, Label, NativeId, Page, PageRequest,
18 Project, ProjectQuery, SourceError, SourceName, Task, TaskQuery, TaskSource, WriteSupport,
19};
20use serde::Deserialize;
21use serde_json::{Value, json};
22
23use super::connection::{Connection, Peer};
24use super::wire::{
25 DependencyParams, EngineIdentity, IdParams, InitializeParams, InitializeResult, LabelParams,
26 PROTOCOL_VERSION, ProjectQueryParams, ProjectResult, ProjectWriteParams, Request,
27 TaskQueryParams, TaskResult, TaskWriteParams, WriteResult,
28};
29
30const HANDSHAKE_ID: &str = "0";
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub struct RequestDeadline(NonZeroU64);
38
39impl RequestDeadline {
40 pub const DEFAULT: Self = Self(NonZeroU64::new(30_000).expect("non-zero default"));
42
43 #[must_use]
45 pub const fn from_millis(milliseconds: NonZeroU64) -> Self {
46 Self(milliseconds)
47 }
48
49 #[must_use]
51 pub const fn milliseconds(self) -> NonZeroU64 {
52 self.0
53 }
54
55 fn duration(self) -> Duration {
56 Duration::from_millis(self.0.get())
57 }
58}
59
60pub struct SubprocessSource {
62 kind: &'static str,
70 capabilities: Capabilities,
72 writes: WriteSupport,
78 connection: Connection,
80}
81
82impl std::fmt::Debug for SubprocessSource {
83 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
86 f.debug_struct("SubprocessSource")
87 .field("kind", &self.kind)
88 .finish_non_exhaustive()
89 }
90}
91
92impl SubprocessSource {
93 pub fn connect(
102 program: &str,
103 args: &[String],
104 name: &SourceName,
105 config: &Value,
106 secrets: BTreeMap<String, String>,
107 ) -> Result<Self, SourceError> {
108 Self::connect_with_deadline(
109 program,
110 args,
111 name,
112 config,
113 secrets,
114 RequestDeadline::DEFAULT,
115 )
116 }
117
118 pub fn connect_with_deadline(
120 program: &str,
121 args: &[String],
122 name: &SourceName,
123 config: &Value,
124 secrets: BTreeMap<String, String>,
125 deadline: RequestDeadline,
126 ) -> Result<Self, SourceError> {
127 Self::adopt(
128 Peer::spawn(program, args, deadline.duration())?,
129 name,
130 config,
131 secrets,
132 )
133 }
134
135 pub fn over(
150 to_plugin: impl std::io::Write + Send + 'static,
151 from_plugin: impl std::io::Read + Send + 'static,
152 name: &SourceName,
153 config: &Value,
154 secrets: BTreeMap<String, String>,
155 ) -> Result<Self, SourceError> {
156 Self::over_with_request_deadline(
157 to_plugin,
158 from_plugin,
159 name,
160 config,
161 secrets,
162 RequestDeadline::DEFAULT,
163 )
164 }
165
166 pub fn over_with_request_deadline(
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 deadline: RequestDeadline,
178 ) -> Result<Self, SourceError> {
179 Self::adopt(
180 Peer::over(to_plugin, from_plugin, deadline.duration()),
181 name,
182 config,
183 secrets,
184 )
185 }
186
187 fn adopt(
189 mut peer: Peer,
190 name: &SourceName,
191 config: &Value,
192 secrets: BTreeMap<String, String>,
193 ) -> Result<Self, SourceError> {
194 let result = Self::handshake(&mut peer, name, config, secrets);
195 let InitializeResult {
196 protocol_version,
197 kind,
198 capabilities,
199 writes,
200 } = match result {
201 Ok(result) => result,
202 Err(error) => return Err(with_diagnostics(error, &mut peer)),
203 };
204 let kind = kind.into_string();
205 if protocol_version != Some(PROTOCOL_VERSION) {
206 return Err(SourceError::Config {
207 message: match protocol_version {
208 Some(spoken) => format!(
209 "the {kind:?} plugin was asked for protocol version \
210 {PROTOCOL_VERSION} and answered in version {spoken}; the two are \
211 incompatible and this engine does not guess between them"
212 ),
213 None => format!(
214 "the {kind:?} plugin did not say which protocol version it \
215 answered in; this engine speaks version {PROTOCOL_VERSION} and \
216 does not guess"
217 ),
218 },
219 });
220 }
221 Ok(Self {
222 kind: String::leak(kind),
223 capabilities,
224 writes: writes.unwrap_or(WriteSupport::Unsupported),
225 connection: Connection::adopt(peer),
226 })
227 }
228
229 fn handshake(
231 peer: &mut Peer,
232 name: &SourceName,
233 config: &Value,
234 secrets: BTreeMap<String, String>,
235 ) -> Result<InitializeResult, SourceError> {
236 let params = InitializeParams {
237 protocol_version: PROTOCOL_VERSION,
238 engine: EngineIdentity {
239 name: "onetaskgraph".to_owned(),
240 version: env!("CARGO_PKG_VERSION").to_owned(),
241 },
242 source_name: name.as_str().to_owned(),
243 config: config.clone(),
244 secrets,
245 };
246 let request = Request {
247 id: HANDSHAKE_ID.to_owned(),
248 method: "initialize".to_owned(),
249 params: serde_json::to_value(¶ms).expect("a handshake is plain data"),
252 };
253 let line = peer.exchange(
254 &serde_json::to_string(&request).expect("a handshake request is plain data"),
255 )?;
256 let response: super::wire::Response =
257 serde_json::from_str(&line).map_err(|error| SourceError::Malformed {
258 message: format!(
259 "the plugin's handshake answer is not a response envelope: {error}"
260 ),
261 })?;
262 if response.id != HANDSHAKE_ID {
267 return Err(SourceError::Malformed {
268 message: format!(
269 "the plugin answered the handshake with an envelope addressed to {:?} \
270 rather than to {HANDSHAKE_ID:?}",
271 response.id
272 ),
273 });
274 }
275 let outcome = response.outcome().ok_or_else(|| SourceError::Malformed {
276 message: "the plugin's handshake answer carried both a result and an error, or \
277 neither"
278 .to_owned(),
279 })?;
280 let result = outcome?;
281 serde_json::from_value(result).map_err(|error| SourceError::Malformed {
282 message: format!("the plugin's handshake answer is not an initialize result: {error}"),
283 })
284 }
285
286 async fn ask<T: for<'de> Deserialize<'de>>(
288 &self,
289 method: &str,
290 params: Value,
291 ) -> Result<T, SourceError> {
292 let result = self.connection.call(method, params).await?;
293 serde_json::from_value(result).map_err(|error| SourceError::Malformed {
294 message: format!(
295 "the plugin's answer to {method} is not the shape it promises: {error}"
296 ),
297 })
298 }
299}
300
301fn with_diagnostics(error: SourceError, peer: &mut Peer) -> SourceError {
306 let said = peer.said();
307 if said.is_empty() {
308 return error;
309 }
310 let message = format!("{error}; the plugin wrote: {said}");
311 match error {
312 SourceError::RateLimited { .. } => error,
313 SourceError::Config { .. } => SourceError::Config { message },
314 SourceError::Auth { .. } => SourceError::Auth { message },
315 SourceError::Refused { .. } => SourceError::Refused { message },
316 SourceError::Malformed { .. } => SourceError::Malformed { message },
317 SourceError::Unavailable { .. } => SourceError::Unavailable { message },
318 }
319}
320
321#[async_trait]
322impl TaskSource for SubprocessSource {
323 fn kind(&self) -> &'static str {
324 self.kind
325 }
326
327 fn capabilities(&self) -> Capabilities {
328 self.capabilities.clone()
329 }
330
331 async fn health(&self) -> Result<Health, SourceError> {
332 self.ask("health", json!({})).await
333 }
334
335 async fn get_task(&self, id: &NativeId) -> Result<Option<Task>, SourceError> {
336 let result: TaskResult = self
337 .ask("get_task", params(&IdParams { id: id.clone() }))
338 .await?;
339 Ok(result.task)
340 }
341
342 async fn get_project(&self, id: &NativeId) -> Result<Option<Project>, SourceError> {
343 let result: ProjectResult = self
344 .ask("get_project", params(&IdParams { id: id.clone() }))
345 .await?;
346 Ok(result.project)
347 }
348
349 async fn query_tasks(
350 &self,
351 query: &TaskQuery,
352 page: &PageRequest,
353 ) -> Result<Page<Task>, SourceError> {
354 self.ask(
355 "query_tasks",
356 params(&TaskQueryParams {
357 query: query.clone(),
358 page: page.clone(),
359 }),
360 )
361 .await
362 }
363
364 async fn query_projects(
365 &self,
366 query: &ProjectQuery,
367 page: &PageRequest,
368 ) -> Result<Page<Project>, SourceError> {
369 self.ask(
370 "query_projects",
371 params(&ProjectQueryParams {
372 query: query.clone(),
373 page: page.clone(),
374 }),
375 )
376 .await
377 }
378
379 async fn labels(&self, page: &PageRequest) -> Result<Page<Label>, SourceError> {
380 self.ask("labels", params(&LabelParams { page: page.clone() }))
381 .await
382 }
383
384 async fn task_dependencies(
385 &self,
386 id: &NativeId,
387 direction: Direction,
388 page: &PageRequest,
389 ) -> Result<Page<DependencyEdge>, SourceError> {
390 self.ask(
391 "task_dependencies",
392 params(&DependencyParams {
393 id: id.clone(),
394 direction,
395 page: page.clone(),
396 }),
397 )
398 .await
399 }
400
401 async fn project_dependencies(
402 &self,
403 id: &NativeId,
404 direction: Direction,
405 page: &PageRequest,
406 ) -> Result<Page<DependencyEdge>, SourceError> {
407 self.ask(
408 "project_dependencies",
409 params(&DependencyParams {
410 id: id.clone(),
411 direction,
412 page: page.clone(),
413 }),
414 )
415 .await
416 }
417
418 fn writes(&self) -> WriteSupport {
419 self.writes
420 }
421
422 async fn write_task(&self, write: &ItemWrite<Task>) -> Result<NativeId, SourceError> {
423 let result: WriteResult = self
424 .ask(
425 "write_task",
426 params(&TaskWriteParams {
427 write: write.clone(),
428 }),
429 )
430 .await?;
431 Ok(result.id)
432 }
433
434 async fn write_project(&self, write: &ItemWrite<Project>) -> Result<NativeId, SourceError> {
435 let result: WriteResult = self
436 .ask(
437 "write_project",
438 params(&ProjectWriteParams {
439 write: write.clone(),
440 }),
441 )
442 .await?;
443 Ok(result.id)
444 }
445}
446
447fn params<T: serde::Serialize>(value: &T) -> Value {
452 serde_json::to_value(value).expect("method parameters are plain data")
453}