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, Label, NativeId, Page, PageRequest, Project,
18 ProjectQuery, SourceError, SourceName, Task, TaskQuery, TaskSource,
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, Request, TaskQueryParams, TaskResult,
27};
28
29const HANDSHAKE_ID: &str = "0";
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub struct RequestDeadline(NonZeroU64);
37
38impl RequestDeadline {
39 pub const DEFAULT: Self = Self(NonZeroU64::new(30_000).expect("non-zero default"));
41
42 #[must_use]
44 pub const fn from_millis(milliseconds: NonZeroU64) -> Self {
45 Self(milliseconds)
46 }
47
48 #[must_use]
50 pub const fn milliseconds(self) -> NonZeroU64 {
51 self.0
52 }
53
54 fn duration(self) -> Duration {
55 Duration::from_millis(self.0.get())
56 }
57}
58
59pub struct SubprocessSource {
61 kind: &'static str,
69 capabilities: Capabilities,
71 connection: Connection,
73}
74
75impl std::fmt::Debug for SubprocessSource {
76 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
79 f.debug_struct("SubprocessSource")
80 .field("kind", &self.kind)
81 .finish_non_exhaustive()
82 }
83}
84
85impl SubprocessSource {
86 pub fn connect(
95 program: &str,
96 args: &[String],
97 name: &SourceName,
98 config: &Value,
99 secrets: BTreeMap<String, String>,
100 ) -> Result<Self, SourceError> {
101 Self::connect_with_deadline(
102 program,
103 args,
104 name,
105 config,
106 secrets,
107 RequestDeadline::DEFAULT,
108 )
109 }
110
111 pub fn connect_with_deadline(
113 program: &str,
114 args: &[String],
115 name: &SourceName,
116 config: &Value,
117 secrets: BTreeMap<String, String>,
118 deadline: RequestDeadline,
119 ) -> Result<Self, SourceError> {
120 Self::adopt(
121 Peer::spawn(program, args, deadline.duration())?,
122 name,
123 config,
124 secrets,
125 )
126 }
127
128 pub fn over(
143 to_plugin: impl std::io::Write + Send + 'static,
144 from_plugin: impl std::io::Read + Send + 'static,
145 name: &SourceName,
146 config: &Value,
147 secrets: BTreeMap<String, String>,
148 ) -> Result<Self, SourceError> {
149 Self::over_with_request_deadline(
150 to_plugin,
151 from_plugin,
152 name,
153 config,
154 secrets,
155 RequestDeadline::DEFAULT,
156 )
157 }
158
159 pub fn over_with_request_deadline(
165 to_plugin: impl std::io::Write + Send + 'static,
166 from_plugin: impl std::io::Read + Send + 'static,
167 name: &SourceName,
168 config: &Value,
169 secrets: BTreeMap<String, String>,
170 deadline: RequestDeadline,
171 ) -> Result<Self, SourceError> {
172 Self::adopt(
173 Peer::over(to_plugin, from_plugin, deadline.duration()),
174 name,
175 config,
176 secrets,
177 )
178 }
179
180 fn adopt(
182 mut peer: Peer,
183 name: &SourceName,
184 config: &Value,
185 secrets: BTreeMap<String, String>,
186 ) -> Result<Self, SourceError> {
187 let result = Self::handshake(&mut peer, name, config, secrets);
188 let InitializeResult {
189 protocol_version,
190 kind,
191 capabilities,
192 } = match result {
193 Ok(result) => result,
194 Err(error) => return Err(with_diagnostics(error, &mut peer)),
195 };
196 let kind = kind.into_string();
197 if protocol_version != Some(PROTOCOL_VERSION) {
198 return Err(SourceError::Config {
199 message: match protocol_version {
200 Some(spoken) => format!(
201 "the {kind:?} plugin was asked for protocol version \
202 {PROTOCOL_VERSION} and answered in version {spoken}; the two are \
203 incompatible and this engine does not guess between them"
204 ),
205 None => format!(
206 "the {kind:?} plugin did not say which protocol version it \
207 answered in; this engine speaks version {PROTOCOL_VERSION} and \
208 does not guess"
209 ),
210 },
211 });
212 }
213 Ok(Self {
214 kind: String::leak(kind),
215 capabilities,
216 connection: Connection::adopt(peer),
217 })
218 }
219
220 fn handshake(
222 peer: &mut Peer,
223 name: &SourceName,
224 config: &Value,
225 secrets: BTreeMap<String, String>,
226 ) -> Result<InitializeResult, SourceError> {
227 let params = InitializeParams {
228 protocol_version: PROTOCOL_VERSION,
229 engine: EngineIdentity {
230 name: "onetaskgraph".to_owned(),
231 version: env!("CARGO_PKG_VERSION").to_owned(),
232 },
233 source_name: name.as_str().to_owned(),
234 config: config.clone(),
235 secrets,
236 };
237 let request = Request {
238 id: HANDSHAKE_ID.to_owned(),
239 method: "initialize".to_owned(),
240 params: serde_json::to_value(¶ms).expect("a handshake is plain data"),
243 };
244 let line = peer.exchange(
245 &serde_json::to_string(&request).expect("a handshake request is plain data"),
246 )?;
247 let response: super::wire::Response =
248 serde_json::from_str(&line).map_err(|error| SourceError::Malformed {
249 message: format!(
250 "the plugin's handshake answer is not a response envelope: {error}"
251 ),
252 })?;
253 if response.id != HANDSHAKE_ID {
258 return Err(SourceError::Malformed {
259 message: format!(
260 "the plugin answered the handshake with an envelope addressed to {:?} \
261 rather than to {HANDSHAKE_ID:?}",
262 response.id
263 ),
264 });
265 }
266 let outcome = response.outcome().ok_or_else(|| SourceError::Malformed {
267 message: "the plugin's handshake answer carried both a result and an error, or \
268 neither"
269 .to_owned(),
270 })?;
271 let result = outcome?;
272 serde_json::from_value(result).map_err(|error| SourceError::Malformed {
273 message: format!("the plugin's handshake answer is not an initialize result: {error}"),
274 })
275 }
276
277 async fn ask<T: for<'de> Deserialize<'de>>(
279 &self,
280 method: &str,
281 params: Value,
282 ) -> Result<T, SourceError> {
283 let result = self.connection.call(method, params).await?;
284 serde_json::from_value(result).map_err(|error| SourceError::Malformed {
285 message: format!(
286 "the plugin's answer to {method} is not the shape it promises: {error}"
287 ),
288 })
289 }
290}
291
292fn with_diagnostics(error: SourceError, peer: &mut Peer) -> SourceError {
297 let said = peer.said();
298 if said.is_empty() {
299 return error;
300 }
301 let message = format!("{error}; the plugin wrote: {said}");
302 match error {
303 SourceError::RateLimited { .. } => error,
304 SourceError::Config { .. } => SourceError::Config { message },
305 SourceError::Auth { .. } => SourceError::Auth { message },
306 SourceError::Refused { .. } => SourceError::Refused { message },
307 SourceError::Malformed { .. } => SourceError::Malformed { message },
308 SourceError::Unavailable { .. } => SourceError::Unavailable { message },
309 }
310}
311
312#[async_trait]
313impl TaskSource for SubprocessSource {
314 fn kind(&self) -> &'static str {
315 self.kind
316 }
317
318 fn capabilities(&self) -> Capabilities {
319 self.capabilities.clone()
320 }
321
322 async fn health(&self) -> Result<Health, SourceError> {
323 self.ask("health", json!({})).await
324 }
325
326 async fn get_task(&self, id: &NativeId) -> Result<Option<Task>, SourceError> {
327 let result: TaskResult = self
328 .ask("get_task", params(&IdParams { id: id.clone() }))
329 .await?;
330 Ok(result.task)
331 }
332
333 async fn get_project(&self, id: &NativeId) -> Result<Option<Project>, SourceError> {
334 let result: ProjectResult = self
335 .ask("get_project", params(&IdParams { id: id.clone() }))
336 .await?;
337 Ok(result.project)
338 }
339
340 async fn query_tasks(
341 &self,
342 query: &TaskQuery,
343 page: &PageRequest,
344 ) -> Result<Page<Task>, SourceError> {
345 self.ask(
346 "query_tasks",
347 params(&TaskQueryParams {
348 query: query.clone(),
349 page: page.clone(),
350 }),
351 )
352 .await
353 }
354
355 async fn query_projects(
356 &self,
357 query: &ProjectQuery,
358 page: &PageRequest,
359 ) -> Result<Page<Project>, SourceError> {
360 self.ask(
361 "query_projects",
362 params(&ProjectQueryParams {
363 query: query.clone(),
364 page: page.clone(),
365 }),
366 )
367 .await
368 }
369
370 async fn labels(&self, page: &PageRequest) -> Result<Page<Label>, SourceError> {
371 self.ask("labels", params(&LabelParams { page: page.clone() }))
372 .await
373 }
374
375 async fn task_dependencies(
376 &self,
377 id: &NativeId,
378 direction: Direction,
379 page: &PageRequest,
380 ) -> Result<Page<DependencyEdge>, SourceError> {
381 self.ask(
382 "task_dependencies",
383 params(&DependencyParams {
384 id: id.clone(),
385 direction,
386 page: page.clone(),
387 }),
388 )
389 .await
390 }
391
392 async fn project_dependencies(
393 &self,
394 id: &NativeId,
395 direction: Direction,
396 page: &PageRequest,
397 ) -> Result<Page<DependencyEdge>, SourceError> {
398 self.ask(
399 "project_dependencies",
400 params(&DependencyParams {
401 id: id.clone(),
402 direction,
403 page: page.clone(),
404 }),
405 )
406 .await
407 }
408}
409
410fn params<T: serde::Serialize>(value: &T) -> Value {
415 serde_json::to_value(value).expect("method parameters are plain data")
416}