onetaskgraph_plugin_api/source.rs
1//! The two traits a plugin implements, and the secret lookup it is handed.
2
3use std::collections::BTreeMap;
4
5use schemars::{JsonSchema, Schema};
6use secrecy::SecretString;
7use serde::{Deserialize, Serialize};
8use serde_json::Value;
9
10use crate::{
11 Asset, AssetName, AssetWrite, AssetsWritten, Capabilities, Comment, CommentBody,
12 DependencyEdge, Direction, Document, DocumentQuery, ItemKind, ItemWrite, Label, MetadataKey,
13 MetadataRecord, Metering, NativeId, NewComment, Page, PageRequest, Priority, Project,
14 ProjectQuery, SharedClock, SourceError, SourceName, Status, StatusCategory, Task,
15 TaskDetailRead, TaskQuery, TaskRef, TaskUpdate, TaskUpdateOutcome, WriteSupport, assetless,
16 commentless, documentless, unwritable, unwritable_field, unwritable_metadata,
17};
18
19/// Whether a source is answering right now.
20///
21/// # Placement is an open contract question
22///
23/// This type lives here because [`TaskSource::health`] returns it and the trait
24/// lives here: placing it in `onetaskgraph-core` would make this crate depend on
25/// the engine and invert the one direction the crate split exists to establish.
26/// The approved contract enumerates this crate's contents exhaustively and does
27/// not name `Health`, so the enumeration and the trait as written cannot both
28/// stand. Compiling forces the placement below; the resolution — add it to the
29/// enumeration, or redesign `health` so no such type crosses the boundary —
30/// belongs to the contract's owner, not to this crate. See `AGENTS.md`.
31#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
32// llmlint: ignore[invalid_states_unrepresentable] SECOND PERMITTED REASON — this restates at a new site the justification already recorded at `Capabilities.max_page_size` (capability.rs) and `PageRequest.limit` (query.rs), and a third time in this type's own doc comment above and in AGENTS.md's "Open contract question — `Health`": `Health`'s shape is approved contract text that `TaskSource::health` returns, so an enum here would change the serialized form and the trait six undispatched nodes implement. That is the contract owner's call, not this crate's.
33pub struct Health {
34 /// Whether the source answered.
35 ///
36 /// A bare `bool` beside an untyped `detail` cannot say that an unreachable source
37 /// must explain itself, or keep "reachable with a warning" apart from "reachable";
38 /// an enum carrying the detail in its unreachable variant would.
39 // llmlint: ignore[invalid_states_unrepresentable] SECOND PERMITTED REASON — this
40 // restates at this field the justification already recorded at
41 // `Capabilities.max_page_size` (capability.rs), `PageRequest.limit` (query.rs), this
42 // type's own doc comment above, and AGENTS.md's "Open contract question — `Health`":
43 // `Health`'s shape is approved contract text that `TaskSource::health` returns, so an
44 // enum here would change the serialized form and the trait six undispatched nodes
45 // implement. That is the contract owner's call, not this crate's.
46 // llmlint: ignore[boundary_inputs_validated] making "unreachable with no reason given" unrepresentable means an enum here, which changes the serialized form and the trait six undispatched nodes implement. Deferred to the contract's owner — AGENTS.md, "Open contract question — `Health`".
47 pub reachable: bool,
48 /// What the source said, when it said anything useful.
49 pub detail: Option<String>,
50}
51
52/// One configured source, as the engine drives it.
53///
54/// Dyn-compatible through `async_trait` because the engine holds
55/// `Vec<Box<dyn TaskSource>>` over heterogeneous plugins.
56///
57/// Three rules bind every implementation, and the engine's compensation is only
58/// correct while all three hold:
59///
60/// 1. **Apply** every predicate you declare [`Support::Native`](crate::Support::Native).
61/// 2. **Ignore** every [`Support`](crate::Support)-typed predicate you declare
62/// `Unsupported` — return the *wider* result set, never a narrower one.
63/// Silently dropping rows for a predicate you did not declare is the one
64/// failure no test above the plugin can catch.
65/// 3. Never return a silently empty dependency read. Rule 2 reaches the
66/// `Support`-typed *predicates* alone; a dependency read is always real, and so is a
67/// document read — [`Capabilities::documents`] says whether this source has documents
68/// at all, and a source that says it has none is never asked for one rather than
69/// answering an empty page.
70#[async_trait::async_trait]
71pub trait TaskSource: Send + Sync {
72 /// The plugin kind that built this source, for display and for plan output.
73 fn kind(&self) -> &'static str;
74
75 /// What this source applies itself. Read once per query by the engine.
76 fn capabilities(&self) -> Capabilities;
77
78 /// Whether the source is answering right now.
79 ///
80 /// # Errors
81 ///
82 /// Returns a [`SourceError`] when the check itself could not be made.
83 async fn health(&self) -> Result<Health, SourceError>;
84
85 /// Fetch one task by its native id, or `None` when there is no such task.
86 ///
87 /// # Errors
88 ///
89 /// Returns a [`SourceError`] when the source could not answer.
90 async fn get_task(&self, id: &NativeId) -> Result<Option<Task>, SourceError>;
91
92 /// Fetch several tasks by their native ids, each with the first page of its comments
93 /// when `comments` names the page to read — one answer per id, in the order they were
94 /// given.
95 ///
96 /// Each answer is what [`get_task`](Self::get_task) answers for that id, so a task that
97 /// is not there is `Ok(None)` and a read that failed fails that id alone. Its
98 /// [`TaskDetailRead::comments`] is what [`task_comments`](Self::task_comments) answers
99 /// for `comments`. The engine passes `comments` only to a source declaring
100 /// [`Capabilities::comments`](crate::Capabilities::comments) native.
101 ///
102 /// Defaulted to exactly those calls, item by item, so a source that implements nothing
103 /// here answers as it always did. A source that can read many items — or an item and its
104 /// comments — in fewer requests than that overrides it, which is the whole reason it
105 /// exists: a caller holding twenty ids pays for one batch rather than forty reads.
106 async fn get_task_details(
107 &self,
108 ids: &[NativeId],
109 comments: Option<&PageRequest>,
110 ) -> Vec<Result<Option<TaskDetailRead>, SourceError>> {
111 let mut read = Vec::with_capacity(ids.len());
112 for id in ids {
113 read.push(match self.get_task(id).await {
114 Ok(Some(task)) => {
115 let comments = match comments {
116 Some(page) => Some(self.task_comments(id, page).await),
117 None => None,
118 };
119 Ok(Some(TaskDetailRead { task, comments }))
120 }
121 Ok(None) => Ok(None),
122 Err(error) => Err(error),
123 });
124 }
125 read
126 }
127
128 /// Fetch one project by its native id, or `None` when there is no such project.
129 ///
130 /// # Errors
131 ///
132 /// Returns a [`SourceError`] when the source could not answer.
133 async fn get_project(&self, id: &NativeId) -> Result<Option<Project>, SourceError>;
134
135 /// One page of the tasks matching `query`.
136 ///
137 /// # Errors
138 ///
139 /// Returns a [`SourceError`] when the source could not answer.
140 async fn query_tasks(
141 &self,
142 query: &TaskQuery,
143 page: &PageRequest,
144 ) -> Result<Page<Task>, SourceError>;
145
146 /// One page of the projects matching `query`.
147 ///
148 /// # Errors
149 ///
150 /// Returns a [`SourceError`] when the source could not answer.
151 async fn query_projects(
152 &self,
153 query: &ProjectQuery,
154 page: &PageRequest,
155 ) -> Result<Page<Project>, SourceError>;
156
157 /// One page of every label this source knows.
158 ///
159 /// # Errors
160 ///
161 /// Returns a [`SourceError`] when the source could not answer.
162 async fn labels(&self, page: &PageRequest) -> Result<Page<Label>, SourceError>;
163
164 /// One page of the task dependency edges at `id`, in `direction`.
165 ///
166 /// # Errors
167 ///
168 /// Returns a [`SourceError`] when the source could not answer.
169 async fn task_dependencies(
170 &self,
171 id: &NativeId,
172 direction: Direction,
173 page: &PageRequest,
174 ) -> Result<Page<DependencyEdge>, SourceError>;
175
176 /// One page of the project dependency edges at `id`, in `direction`.
177 ///
178 /// # Errors
179 ///
180 /// Returns a [`SourceError`] when the source could not answer.
181 async fn project_dependencies(
182 &self,
183 id: &NativeId,
184 direction: Direction,
185 page: &PageRequest,
186 ) -> Result<Page<DependencyEdge>, SourceError>;
187
188 /// Whether this source can be written through at all.
189 ///
190 /// Defaulted to [`WriteSupport::Unsupported`], which is what keeps this a read
191 /// interface for every source that has nothing to write into: one that cannot be
192 /// written needs no edit and keeps working. Read before a write is attempted, so a
193 /// copy naming such a source as its destination is refused before anything is read.
194 fn writes(&self) -> WriteSupport {
195 WriteSupport::Unsupported
196 }
197
198 /// Create or update one task, answering with the native id the destination holds it
199 /// under.
200 ///
201 /// A source declaring [`WriteSupport::Supported`] owes three things here. It refuses,
202 /// naming the field, anything it cannot represent rather than dropping it — including
203 /// a metadata key it cannot carry, which it names. It writes every other field it was
204 /// given. And it never creates when [`ItemWrite::target`] names an item it does not
205 /// hold.
206 ///
207 /// # Errors
208 ///
209 /// Returns [`SourceError::Refused`] when this source has no write side, when a field
210 /// or a metadata key cannot be represented, or when `target` names nothing here; and
211 /// whatever else the source could not do the write for.
212 async fn write_task(&self, write: &ItemWrite<Task>) -> Result<NativeId, SourceError> {
213 let _ = write;
214 Err(unwritable(self.kind()))
215 }
216
217 /// Create or update one project, on exactly the terms of
218 /// [`write_task`](Self::write_task).
219 ///
220 /// # Errors
221 ///
222 /// As [`write_task`](Self::write_task).
223 async fn write_project(&self, write: &ItemWrite<Project>) -> Result<NativeId, SourceError> {
224 let _ = write;
225 Err(unwritable(self.kind()))
226 }
227
228 /// Whether a write of an item of `kind` at `category` — over the item `target` names, or
229 /// creating one when it is `None` — would have a status to write, asked before the write
230 /// and changing nothing.
231 ///
232 /// A caller that has to put an item back when a later write of it fails — a copy, whose
233 /// journal records what an item held before overwriting it — asks this first, so a status
234 /// this source has no name for is refused while nothing has been recorded or written, and
235 /// nothing has to be put back. It answers what the write itself would refuse a status
236 /// with, in the same words, and sends no request a write of that status would not have
237 /// sent anyway: what it reads to answer, it holds for the write that follows.
238 ///
239 /// Defaulted to `Ok(())`: a source that does not answer it in advance still refuses the
240 /// status in its write, exactly as before.
241 ///
242 /// # Errors
243 ///
244 /// Returns [`SourceError::Refused`] naming the kind, the category and what is missing
245 /// when this source has no name for that status; and whatever else kept it from finding
246 /// out.
247 async fn check_status_write(
248 &self,
249 kind: ItemKind,
250 category: StatusCategory,
251 target: Option<&NativeId>,
252 ) -> Result<(), SourceError> {
253 let _ = (kind, category, target);
254 Ok(())
255 }
256
257 /// Set the status of one task this source holds, and change nothing else about it,
258 /// answering with the status as this source now reads it — or `None` when this source
259 /// holds no such task.
260 ///
261 /// The category lands where this source's own mapping sends it, exactly as a
262 /// [`write_task`](Self::write_task) of a task in that category would: a category this
263 /// source has disabled is refused in the words a write of it is refused with. Title,
264 /// content, labels, metadata, dependencies, [`Task::delivers`], [`Task::delivered_by`],
265 /// project and comments are left exactly as they are.
266 ///
267 /// Defaulted to [`unwritable_field`], which is what keeps this an addition rather than a
268 /// break: a source that cannot write a status on its own needs no edit and refuses by
269 /// saying so. A source declaring [`WriteSupport::Unsupported`] is never asked.
270 ///
271 /// [`Task::delivers`]: crate::Task::delivers
272 /// [`Task::delivered_by`]: crate::Task::delivered_by
273 ///
274 /// # Errors
275 ///
276 /// Returns [`SourceError::Refused`] when this source cannot write a status, or cannot
277 /// write this one; and whatever else the source could not do the write for.
278 async fn set_task_status(
279 &self,
280 id: &NativeId,
281 category: StatusCategory,
282 ) -> Result<Option<Status>, SourceError> {
283 let _ = (id, category);
284 Err(unwritable_field(self.kind(), "status"))
285 }
286
287 /// Set the status of one task this source holds, as [`set_task_status`](Self::set_task_status)
288 /// does, and answer with the whole task as this source now reads it — its
289 /// [`Task::delivers`] among what a caller keeping delivered tasks in step needs — or `None`
290 /// when this source holds no such task.
291 ///
292 /// Defaulted to exactly the two calls a caller would otherwise make: [`get_task`] first,
293 /// answering `None` with nothing written when it does, then `set_task_status`, the status
294 /// it answers put on the task read. A source whose status write answers the whole task in
295 /// the same round trip overrides it to save the read.
296 ///
297 /// [`Task::delivers`]: crate::Task::delivers
298 /// [`get_task`]: Self::get_task
299 ///
300 /// # Errors
301 ///
302 /// As [`get_task`](Self::get_task) and [`set_task_status`](Self::set_task_status).
303 async fn set_task_status_reading(
304 &self,
305 id: &NativeId,
306 category: StatusCategory,
307 ) -> Result<Option<Task>, SourceError> {
308 let Some(mut task) = self.get_task(id).await? else {
309 return Ok(None);
310 };
311 let Some(status) = self.set_task_status(id, category).await? else {
312 return Ok(None);
313 };
314 task.status = status;
315 Ok(Some(task))
316 }
317
318 /// Set the priority of one task this source holds, and change nothing else about it,
319 /// answering with the priority as this source now reads it — or `None` when this source
320 /// holds no such task.
321 ///
322 /// [`Priority::None`] clears the priority. Title, content, status, labels, metadata,
323 /// repositories, dependencies and comments are left exactly as they are. Nothing about
324 /// the task's status moves, so the engine re-evaluates no delivered task after it.
325 ///
326 /// Defaulted to [`unwritable_field`], which is what keeps this an addition rather than a
327 /// break. A source declaring [`WriteSupport::Unsupported`], or declaring
328 /// [`Capabilities::priority`] unsupported, is never asked.
329 ///
330 /// # Errors
331 ///
332 /// Returns [`SourceError::Refused`] when this source cannot write a priority, or cannot
333 /// write this one — a board with no option for it, naming the option; and whatever else
334 /// the source could not do the write for.
335 async fn set_task_priority(
336 &self,
337 id: &NativeId,
338 priority: Priority,
339 ) -> Result<Option<Priority>, SourceError> {
340 let _ = (id, priority);
341 Err(unwritable_field(self.kind(), "priority"))
342 }
343
344 /// Replace the content of one task this source holds with `content`, byte for byte, and
345 /// change nothing else about it — or answer `None` when this source holds no such task.
346 ///
347 /// The content is [`Task::content`] exactly as this source reports it: what a later read
348 /// answers there is `content`. Where the source keeps something else inside the same
349 /// backend field — a metadata block in an issue body — that is kept as it was, and so is
350 /// every other member: title, status, priority, labels, metadata, repositories,
351 /// dependencies, project and comments. Nothing about the task's status moves, so the
352 /// engine re-evaluates no delivered task after it.
353 ///
354 /// Defaulted to [`unwritable_field`] on exactly the terms of
355 /// [`set_task_status`](Self::set_task_status). A source declaring
356 /// [`WriteSupport::Unsupported`] is never asked.
357 ///
358 /// # Errors
359 ///
360 /// Returns [`SourceError::Refused`] when this source cannot write a task's content on its
361 /// own, or cannot represent this one; and whatever else the source could not do the write
362 /// for.
363 async fn set_task_content(
364 &self,
365 id: &NativeId,
366 content: &str,
367 ) -> Result<Option<()>, SourceError> {
368 let _ = (id, content);
369 Err(unwritable_field(self.kind(), "content"))
370 }
371
372 /// Replace the [`Task::delivered_by`] of one task this source holds, and change nothing
373 /// else about it — or answer `None` when this source holds no such task.
374 ///
375 /// Every entry is a qualified id, and the list is the whole of it: what the task held
376 /// there before is replaced, not merged. It is the store's to keep in step — the engine
377 /// calls this whenever it writes a task's [`Task::delivers`] — and nothing a person types
378 /// reaches it directly.
379 ///
380 /// Defaulted to [`unwritable_field`] on exactly the terms of
381 /// [`set_task_status`](Self::set_task_status).
382 ///
383 /// [`Task::delivers`]: crate::Task::delivers
384 /// [`Task::delivered_by`]: crate::Task::delivered_by
385 ///
386 /// # Errors
387 ///
388 /// Returns [`SourceError::Refused`] when this source cannot hold the list, and whatever
389 /// else it could not do the write for.
390 async fn set_delivered_by(
391 &self,
392 id: &NativeId,
393 delivered_by: &[TaskRef],
394 ) -> Result<Option<()>, SourceError> {
395 let _ = (id, delivered_by);
396 Err(unwritable_field(self.kind(), "delivered_by"))
397 }
398
399 /// Set one key of the metadata of one task this source holds, and change nothing else
400 /// about it, answering with the task as this source reads it back after the write — or
401 /// `None` when this source holds no such task.
402 ///
403 /// The key is added when the task does not hold it and replaced when it does; every other
404 /// metadata key, and every other field of the task, is left exactly as it was. A `value`
405 /// the task already holds under `key` is a write that changes nothing, and a source owes
406 /// it no write at all. The answer is a read, not an echo: what the engine reports as the
407 /// value is what the returned task holds under `key`.
408 ///
409 /// Nothing about the task's status or its [`Task::delivers`] moves, so the engine
410 /// re-evaluates no delivered task after this write.
411 ///
412 /// Defaulted to [`unwritable_metadata`] on exactly the terms of
413 /// [`set_task_status`](Self::set_task_status): a source that cannot write one key on its
414 /// own needs no edit and refuses by saying so. A source declaring
415 /// [`WriteSupport::Unsupported`] is never asked.
416 ///
417 /// [`Task::delivers`]: crate::Task::delivers
418 ///
419 /// # Errors
420 ///
421 /// Returns [`SourceError::Refused`] when this source cannot write one key of a task's
422 /// metadata on its own, or cannot write this one without changing something else; and
423 /// whatever else the source could not do the write for.
424 async fn set_task_metadata(
425 &self,
426 id: &NativeId,
427 key: &MetadataKey,
428 value: &Value,
429 ) -> Result<Option<Task>, SourceError> {
430 let _ = (id, key, value);
431 Err(unwritable_metadata(self.kind(), MetadataRecord::Task))
432 }
433
434 /// Apply a targeted update to one task this source holds — every field `update` names, and
435 /// nothing else — answering with a [`TaskUpdateOutcome`], or `None` when this source holds
436 /// no such task.
437 ///
438 /// The outcome is everything the engine reports about the call, so it reads nothing of its
439 /// own: the task as this source reads it once the update landed — from the one read it made
440 /// and what it then wrote, where its backend answers a write with what it holds — the
441 /// fields it actually wrote, and the `delivers` the task held before.
442 ///
443 /// A field already holding the requested value is sent no write, and an update in which
444 /// nothing differs sends no write at all. Every field `update` leaves unnamed — labels,
445 /// repositories, project, [`Task::delivered_by`] and comments always among them — is left
446 /// exactly as it is. An update naming one metadata key both to set and to remove is refused
447 /// before anything is written, in the words of [`TaskUpdate::consistent`].
448 ///
449 /// Defaulted to [`TaskUpdate::rewrite`]: read the task, apply the update, and — only when
450 /// anything differs — [`write_task`](Self::write_task) it with its `target` set. That keeps
451 /// this an addition rather than a break, and a source that predates it behaves correctly and
452 /// is merely not minimal; one whose backend can take a field on its own owes an override
453 /// that sends only what differs. A source declaring [`WriteSupport::Unsupported`] is never
454 /// asked.
455 ///
456 /// [`Task::delivered_by`]: crate::Task::delivered_by
457 ///
458 /// # Errors
459 ///
460 /// Returns [`SourceError::Refused`] when the update contradicts itself, when a field it
461 /// names cannot be represented here — a category this source has disabled included — and
462 /// whatever else the source could not do the write for.
463 async fn update_task(
464 &self,
465 id: &NativeId,
466 update: &TaskUpdate,
467 ) -> Result<Option<TaskUpdateOutcome>, SourceError> {
468 update.rewrite(self, id).await
469 }
470
471 /// Set one key of the metadata of one project this source holds, on exactly the terms of
472 /// [`set_task_metadata`](Self::set_task_metadata).
473 ///
474 /// # Errors
475 ///
476 /// As [`set_task_metadata`](Self::set_task_metadata).
477 async fn set_project_metadata(
478 &self,
479 id: &NativeId,
480 key: &MetadataKey,
481 value: &Value,
482 ) -> Result<Option<Project>, SourceError> {
483 let _ = (id, key, value);
484 Err(unwritable_metadata(self.kind(), MetadataRecord::Project))
485 }
486
487 /// Set one key of the metadata of one document this source holds, on exactly the terms of
488 /// [`set_task_metadata`](Self::set_task_metadata).
489 ///
490 /// A source declaring [`Capabilities::documents`] unsupported is never asked, exactly as
491 /// it is never asked for a document read.
492 ///
493 /// # Errors
494 ///
495 /// As [`set_task_metadata`](Self::set_task_metadata).
496 async fn set_document_metadata(
497 &self,
498 id: &NativeId,
499 key: &MetadataKey,
500 value: &Value,
501 ) -> Result<Option<Document>, SourceError> {
502 let _ = (id, key, value);
503 Err(unwritable_metadata(self.kind(), MetadataRecord::Document))
504 }
505
506 /// Remove one task this destination holds, so a copy that could not finish can put
507 /// the destination back the way it found it.
508 ///
509 /// This is not a verb of the product: nothing a user types deletes anything, and a
510 /// copy never deletes an item it did not itself create in the run that is failing.
511 /// It exists because a copy is either complete or it never happened — a half-written
512 /// project has to be run again, and the re-run is the mutation burst that trips a
513 /// hosted destination's rate limiter. Undoing this run's own creates is what removes
514 /// that retry at source.
515 ///
516 /// A source declaring [`WriteSupport::Supported`] owes a real implementation, for the
517 /// reason it owes [`write_task`](Self::write_task) one: the engine will create items
518 /// there, so it has to be able to remove the ones it created. An `id` naming nothing
519 /// is **not** an error — the item is already gone, which is the state this asks for.
520 ///
521 /// # Errors
522 ///
523 /// Returns [`SourceError::Refused`] when this source has no write side, and whatever
524 /// else the source could not remove the item for.
525 async fn delete_task(&self, id: &NativeId) -> Result<(), SourceError> {
526 let _ = id;
527 Err(unwritable(self.kind()))
528 }
529
530 /// Remove one project this destination holds, on exactly the terms of
531 /// [`delete_task`](Self::delete_task).
532 ///
533 /// # Errors
534 ///
535 /// As [`delete_task`](Self::delete_task).
536 async fn delete_project(&self, id: &NativeId) -> Result<(), SourceError> {
537 let _ = id;
538 Err(unwritable(self.kind()))
539 }
540
541 /// Fetch one document by its native id, or `None` when there is no such document.
542 ///
543 /// Defaulted to [`documentless`], which is what keeps documents an addition rather
544 /// than a break: a source with none needs no edit, keeps working, and says so in the
545 /// same words every other document-free source does. A source that has documents
546 /// declares [`Support::Native`](crate::Support::Native) for
547 /// [`Capabilities::documents`] and owes a real implementation here, because that
548 /// declaration is what makes the engine ask.
549 ///
550 /// # Errors
551 ///
552 /// Returns [`SourceError::Refused`] when this source has no documents, and whatever
553 /// else the source could not answer for.
554 async fn get_document(&self, id: &NativeId) -> Result<Option<Document>, SourceError> {
555 let _ = id;
556 Err(documentless(self.kind()))
557 }
558
559 /// One page of the documents matching `query`.
560 ///
561 /// Defaulted on exactly the terms of [`get_document`](Self::get_document). A source
562 /// with no documents refuses rather than answering an empty page: an empty page reads
563 /// as a source that has documents and holds none matching, which is the one wrong
564 /// answer this method can give.
565 ///
566 /// # Errors
567 ///
568 /// As [`get_document`](Self::get_document).
569 async fn query_documents(
570 &self,
571 query: &DocumentQuery,
572 page: &PageRequest,
573 ) -> Result<Page<Document>, SourceError> {
574 let _ = (query, page);
575 Err(documentless(self.kind()))
576 }
577
578 /// Create or update one document, on exactly the terms of
579 /// [`write_task`](Self::write_task).
580 ///
581 /// # Errors
582 ///
583 /// As [`write_task`](Self::write_task).
584 async fn write_document(&self, write: &ItemWrite<Document>) -> Result<NativeId, SourceError> {
585 let _ = write;
586 Err(unwritable(self.kind()))
587 }
588
589 /// Remove one document this destination holds, on exactly the terms of
590 /// [`delete_task`](Self::delete_task).
591 ///
592 /// # Errors
593 ///
594 /// As [`delete_task`](Self::delete_task).
595 async fn delete_document(&self, id: &NativeId) -> Result<(), SourceError> {
596 let _ = id;
597 Err(unwritable(self.kind()))
598 }
599
600 /// One page of the comments on `task`, oldest first, or `None` when this source holds
601 /// no such task.
602 ///
603 /// Defaulted to [`commentless`], which is what keeps comments an addition rather than a
604 /// break: a source with none needs no edit and keeps working. A source whose tasks have
605 /// comments declares [`Support::Native`](crate::Support::Native) for
606 /// [`Capabilities::comments`] and owes a real implementation of all four comment methods,
607 /// because that declaration is what makes the engine ask.
608 ///
609 /// "No such task" is `None` rather than an error, exactly as it is for
610 /// [`get_task`](Self::get_task); a task that exists and has no comments is an empty page.
611 ///
612 /// # Errors
613 ///
614 /// Returns [`SourceError::Refused`] when this source has no comments, and whatever else
615 /// the source could not answer for.
616 async fn task_comments(
617 &self,
618 task: &NativeId,
619 page: &PageRequest,
620 ) -> Result<Option<Page<Comment>>, SourceError> {
621 let _ = (task, page);
622 Err(commentless(self.kind()))
623 }
624
625 /// Add one comment to `task`, answering with the comment as the source now holds it, or
626 /// `None` when this source holds no such task.
627 ///
628 /// The body is stored byte for byte. A source that records the author itself refuses a
629 /// [`NewComment::author`] rather than dropping it, naming why; a source that cannot
630 /// represent the body refuses it, naming why, rather than escaping it into something
631 /// else.
632 ///
633 /// # Errors
634 ///
635 /// Returns [`SourceError::Refused`] when this source has no comments or cannot be
636 /// written, when it cannot record what it was given, and whatever else it could not do
637 /// the write for.
638 async fn add_comment(
639 &self,
640 task: &NativeId,
641 comment: &NewComment,
642 ) -> Result<Option<Comment>, SourceError> {
643 let _ = (task, comment);
644 Err(commentless(self.kind()))
645 }
646
647 /// Replace the body of the comment `comment` on `task`, answering with the comment as the
648 /// source now holds it, or `None` when this source holds no such task or that task has no
649 /// such comment.
650 ///
651 /// Only the body and the time it last changed move: the id, the author and the time it
652 /// was written are the comment's own.
653 ///
654 /// # Errors
655 ///
656 /// As [`add_comment`](Self::add_comment).
657 async fn edit_comment(
658 &self,
659 task: &NativeId,
660 comment: &NativeId,
661 body: &CommentBody,
662 ) -> Result<Option<Comment>, SourceError> {
663 let _ = (task, comment, body);
664 Err(commentless(self.kind()))
665 }
666
667 /// Remove the comment `comment` from `task`, answering with the id it removed, or `None`
668 /// when this source holds no such task or that task has no such comment.
669 ///
670 /// Unlike [`delete_task`](Self::delete_task), this *is* a verb of the product — a person
671 /// removes a comment they posted — so a comment that is not there is reported as `None`
672 /// for the engine to refuse by name, rather than treated as already gone.
673 ///
674 /// # Errors
675 ///
676 /// As [`add_comment`](Self::add_comment).
677 async fn delete_comment(
678 &self,
679 task: &NativeId,
680 comment: &NativeId,
681 ) -> Result<Option<NativeId>, SourceError> {
682 let _ = (task, comment);
683 Err(commentless(self.kind()))
684 }
685
686 /// Whether this source keeps template answers beside its items at all.
687 ///
688 /// What lets a regenerate tell an item whose answers went missing — a block deleted by
689 /// hand, which the regenerate writes back — from an item of a source that never keeps any,
690 /// where a missing answer is no difference. Defaulted to `false`, as
691 /// [`task_template_answers`](Self::task_template_answers) is defaulted to `None`; a source
692 /// that keeps answers answers `true` here.
693 fn keeps_template_answers(&self) -> bool {
694 false
695 }
696
697 /// The template answers the task `id` was last rendered from, as this source keeps them
698 /// beside the task — or `None` when it keeps none for it, or holds no such task.
699 ///
700 /// Keeping answers is a source's own choice, never an obligation: a source whose items
701 /// are one record in a hosted system has no room beside the item that is not the item, and
702 /// answers written into its content or its metadata would duplicate what the content
703 /// already says. Defaulted to `None`, which is what keeps this an addition rather than a
704 /// break. A source that keeps them owes three things: they are in neither
705 /// [`Task::content`] nor [`Task::metadata`], they are written only by
706 /// [`write_task_rendered`](Self::write_task_rendered) and
707 /// [`set_task_rendering`](Self::set_task_rendering), and they come back here exactly as
708 /// they were written, JSON types intact.
709 ///
710 /// # Errors
711 ///
712 /// Returns a [`SourceError`] when the source could not answer, a record whose answers it
713 /// cannot read included.
714 async fn task_template_answers(
715 &self,
716 id: &NativeId,
717 ) -> Result<Option<BTreeMap<String, Value>>, SourceError> {
718 let _ = id;
719 Ok(None)
720 }
721
722 /// The template answers the document `id` was last rendered from, on exactly the terms of
723 /// [`task_template_answers`](Self::task_template_answers).
724 ///
725 /// # Errors
726 ///
727 /// As [`task_template_answers`](Self::task_template_answers).
728 async fn document_template_answers(
729 &self,
730 id: &NativeId,
731 ) -> Result<Option<BTreeMap<String, Value>>, SourceError> {
732 let _ = id;
733 Ok(None)
734 }
735
736 /// The template answers the project `id` was last rendered from, on exactly the terms of
737 /// [`task_template_answers`](Self::task_template_answers).
738 ///
739 /// # Errors
740 ///
741 /// As [`task_template_answers`](Self::task_template_answers).
742 async fn project_template_answers(
743 &self,
744 id: &NativeId,
745 ) -> Result<Option<BTreeMap<String, Value>>, SourceError> {
746 let _ = id;
747 Ok(None)
748 }
749
750 /// Create or update one task exactly as [`write_task`](Self::write_task) does, keeping
751 /// `answers` — the answers its content was rendered from — beside it in the same write
752 /// where this source keeps answers at all.
753 ///
754 /// The task arrives carrying its provenance under [`MetadataKey::TEMPLATE_KEY`] like any
755 /// other metadata entry. Defaulted to [`write_task`](Self::write_task) alone: a source that
756 /// keeps no answers writes the task and nothing beside it, which is the whole of what it
757 /// owes. A source that keeps them writes the task and the answers together, so a reader
758 /// never finds one without the other.
759 ///
760 /// # Errors
761 ///
762 /// As [`write_task`](Self::write_task).
763 async fn write_task_rendered(
764 &self,
765 write: &ItemWrite<Task>,
766 answers: &BTreeMap<String, Value>,
767 ) -> Result<NativeId, SourceError> {
768 let _ = answers;
769 self.write_task(write).await
770 }
771
772 /// Create or update one document, on exactly the terms of
773 /// [`write_task_rendered`](Self::write_task_rendered).
774 ///
775 /// # Errors
776 ///
777 /// As [`write_document`](Self::write_document).
778 async fn write_document_rendered(
779 &self,
780 write: &ItemWrite<Document>,
781 answers: &BTreeMap<String, Value>,
782 ) -> Result<NativeId, SourceError> {
783 let _ = answers;
784 self.write_document(write).await
785 }
786
787 /// Create or update one project, on exactly the terms of
788 /// [`write_task_rendered`](Self::write_task_rendered).
789 ///
790 /// # Errors
791 ///
792 /// As [`write_project`](Self::write_project).
793 async fn write_project_rendered(
794 &self,
795 write: &ItemWrite<Project>,
796 answers: &BTreeMap<String, Value>,
797 ) -> Result<NativeId, SourceError> {
798 let _ = answers;
799 self.write_project(write).await
800 }
801
802 /// Replace one task's rendering — its content, byte for byte, its
803 /// [`MetadataKey::TEMPLATE_KEY`] entry, set to `provenance`, and the answers it keeps
804 /// beside the task where it keeps any — in one write, and change nothing else about it;
805 /// or answer `None` when this source holds no such task.
806 ///
807 /// The content is [`Task::content`] exactly as a later read reports it, on the terms of
808 /// [`set_task_content`](Self::set_task_content). Title, status, priority, labels, every
809 /// other metadata entry, repositories, dependencies, [`Task::delivers`],
810 /// [`Task::delivered_by`], project and comments are left exactly as they are. The write is
811 /// one write: a reader sees the task as it was or as it is now, never a new content beside
812 /// the old provenance or the old answers.
813 ///
814 /// Defaulted to [`unwritable_field`] on exactly the terms of
815 /// [`set_task_status`](Self::set_task_status). A source declaring
816 /// [`WriteSupport::Unsupported`] is never asked.
817 ///
818 /// [`Task::delivers`]: crate::Task::delivers
819 /// [`Task::delivered_by`]: crate::Task::delivered_by
820 ///
821 /// # Errors
822 ///
823 /// Returns [`SourceError::Refused`] when this source cannot replace a task's rendering on
824 /// its own, or cannot represent this one; and whatever else the source could not do the
825 /// write for.
826 async fn set_task_rendering(
827 &self,
828 id: &NativeId,
829 content: &str,
830 provenance: &Value,
831 answers: &BTreeMap<String, Value>,
832 ) -> Result<Option<()>, SourceError> {
833 let _ = (id, content, provenance, answers);
834 Err(unwritable_field(self.kind(), "rendering"))
835 }
836
837 /// Replace one document's rendering, on exactly the terms of
838 /// [`set_task_rendering`](Self::set_task_rendering). A source declaring
839 /// [`Capabilities::documents`] unsupported is never asked.
840 ///
841 /// # Errors
842 ///
843 /// As [`set_task_rendering`](Self::set_task_rendering).
844 async fn set_document_rendering(
845 &self,
846 id: &NativeId,
847 content: &str,
848 provenance: &Value,
849 answers: &BTreeMap<String, Value>,
850 ) -> Result<Option<()>, SourceError> {
851 let _ = (id, content, provenance, answers);
852 Err(SourceError::Refused {
853 message: format!(
854 "the {} plugin cannot write a document's rendering on its own",
855 self.kind()
856 ),
857 })
858 }
859
860 /// Replace one project's rendering, on exactly the terms of
861 /// [`set_task_rendering`](Self::set_task_rendering): its content, its
862 /// [`MetadataKey::TEMPLATE_KEY`] entry and the answers kept beside it change, and its
863 /// title, status, labels, every other metadata entry, repositories and dependencies are
864 /// left exactly as they are.
865 ///
866 /// # Errors
867 ///
868 /// As [`set_task_rendering`](Self::set_task_rendering).
869 async fn set_project_rendering(
870 &self,
871 id: &NativeId,
872 content: &str,
873 provenance: &Value,
874 answers: &BTreeMap<String, Value>,
875 ) -> Result<Option<()>, SourceError> {
876 let _ = (id, content, provenance, answers);
877 Err(SourceError::Refused {
878 message: format!(
879 "the {} plugin cannot write a project's rendering on its own",
880 self.kind()
881 ),
882 })
883 }
884
885 /// The image assets the task `id` holds, or none when it holds none or this source holds
886 /// no such task.
887 ///
888 /// Defaulted to none, which is what keeps assets an addition rather than a break: a source
889 /// that keeps no assets needs no edit and reports every record as holding none. A source
890 /// declaring [`Capabilities::assets`] native answers what it holds for the record, in any
891 /// order — the engine orders them by the content's first reference.
892 ///
893 /// # Errors
894 ///
895 /// Returns a [`SourceError`] when the source could not answer.
896 async fn task_assets(&self, id: &NativeId) -> Result<Vec<Asset>, SourceError> {
897 let _ = id;
898 Ok(Vec::new())
899 }
900
901 /// The image assets the document `id` holds, on exactly the terms of
902 /// [`task_assets`](Self::task_assets).
903 ///
904 /// # Errors
905 ///
906 /// As [`task_assets`](Self::task_assets).
907 async fn document_assets(&self, id: &NativeId) -> Result<Vec<Asset>, SourceError> {
908 let _ = id;
909 Ok(Vec::new())
910 }
911
912 /// The bytes of the asset `name` the task `id` holds, or `None` when it holds no such asset
913 /// or this source holds no such task.
914 ///
915 /// Defaulted to `None`, on the terms of [`task_assets`](Self::task_assets).
916 ///
917 /// # Errors
918 ///
919 /// Returns a [`SourceError`] when the source could not answer.
920 async fn task_asset(
921 &self,
922 id: &NativeId,
923 name: &AssetName,
924 ) -> Result<Option<Vec<u8>>, SourceError> {
925 let _ = (id, name);
926 Ok(None)
927 }
928
929 /// The bytes of the asset `name` the document `id` holds, on exactly the terms of
930 /// [`task_asset`](Self::task_asset).
931 ///
932 /// # Errors
933 ///
934 /// As [`task_asset`](Self::task_asset).
935 async fn document_asset(
936 &self,
937 id: &NativeId,
938 name: &AssetName,
939 ) -> Result<Option<Vec<u8>>, SourceError> {
940 let _ = (id, name);
941 Ok(None)
942 }
943
944 /// Create or update one task carrying image assets, in one write: the task as
945 /// [`write_task`](Self::write_task) writes it — or, with `answers`, as
946 /// [`write_task_rendered`](Self::write_task_rendered) does — and `assets` as the record's
947 /// whole asset set, answering with where it landed and the content as stored.
948 ///
949 /// Only a source declaring [`Capabilities::assets`] native is asked, and the engine has
950 /// already checked that every reference in the content names one of `assets` and every one
951 /// of `assets` is referenced. A source that keeps the bytes beside the record stores each
952 /// payload under its name, removes every asset the record held that `assets` does not
953 /// name, and answers the content unchanged. A source that serves them at a URL uploads each
954 /// payload that carries bytes, reuses the URL [`AssetWrite::recorded_assets`] records for
955 /// each that does not, and writes the record through
956 /// [`serve_asset_references`](crate::serve_asset_references) — so its references point at
957 /// those URLs, its [`MetadataKey::ASSETS_KEY`] records each `{sha256, url}`, and a
958 /// rendering's `body_digest` stays true of what landed — answering the content it wrote.
959 ///
960 /// Defaulted to [`assetless`], which is what a source with no assets answers.
961 ///
962 /// # Errors
963 ///
964 /// As [`write_task`](Self::write_task), and [`SourceError::Refused`] from a source that
965 /// keeps no assets.
966 async fn write_task_with_assets(
967 &self,
968 write: &ItemWrite<Task>,
969 answers: Option<&BTreeMap<String, Value>>,
970 assets: &AssetWrite,
971 ) -> Result<AssetsWritten, SourceError> {
972 let _ = (write, answers, assets);
973 Err(assetless(self.kind()))
974 }
975
976 /// Create or update one document carrying image assets, on exactly the terms of
977 /// [`write_task_with_assets`](Self::write_task_with_assets).
978 ///
979 /// # Errors
980 ///
981 /// As [`write_task_with_assets`](Self::write_task_with_assets).
982 async fn write_document_with_assets(
983 &self,
984 write: &ItemWrite<Document>,
985 answers: Option<&BTreeMap<String, Value>>,
986 assets: &AssetWrite,
987 ) -> Result<AssetsWritten, SourceError> {
988 let _ = (write, answers, assets);
989 Err(assetless(self.kind()))
990 }
991
992 /// Replace one task's rendering as [`set_task_rendering`](Self::set_task_rendering) does,
993 /// and its asset set as [`write_task_with_assets`](Self::write_task_with_assets) does, in
994 /// one write; or answer `None` when this source holds no such task.
995 ///
996 /// Defaulted to [`assetless`].
997 ///
998 /// # Errors
999 ///
1000 /// As [`set_task_rendering`](Self::set_task_rendering), and [`SourceError::Refused`] from a
1001 /// source that keeps no assets.
1002 async fn set_task_rendering_with_assets(
1003 &self,
1004 id: &NativeId,
1005 content: &str,
1006 provenance: &Value,
1007 answers: &BTreeMap<String, Value>,
1008 assets: &AssetWrite,
1009 ) -> Result<Option<AssetsWritten>, SourceError> {
1010 let _ = (id, content, provenance, answers, assets);
1011 Err(assetless(self.kind()))
1012 }
1013
1014 /// Replace one document's rendering and its asset set, on exactly the terms of
1015 /// [`set_task_rendering_with_assets`](Self::set_task_rendering_with_assets).
1016 ///
1017 /// # Errors
1018 ///
1019 /// As [`set_task_rendering_with_assets`](Self::set_task_rendering_with_assets).
1020 async fn set_document_rendering_with_assets(
1021 &self,
1022 id: &NativeId,
1023 content: &str,
1024 provenance: &Value,
1025 answers: &BTreeMap<String, Value>,
1026 assets: &AssetWrite,
1027 ) -> Result<Option<AssetsWritten>, SourceError> {
1028 let _ = (id, content, provenance, answers, assets);
1029 Err(assetless(self.kind()))
1030 }
1031
1032 /// What this source has sent to its backend since it was built and what that spent, or
1033 /// `None` when it does not meter its own requests.
1034 ///
1035 /// Defaulted to `None`, which is what keeps metering an addition rather than a break: a
1036 /// source that does not count its requests needs no edit, and is reported as not
1037 /// metering rather than as having spent nothing. A source that answers owes a running
1038 /// total — see [`Metering`] — because what one command spent is read as the difference
1039 /// between two readings.
1040 ///
1041 /// # Errors
1042 ///
1043 /// Returns a [`SourceError`] when the reading itself could not be taken. A caller
1044 /// reports such a source as not metering; what a command cost is never a reason for the
1045 /// command to fail.
1046 async fn metering(&self) -> Result<Option<Metering>, SourceError> {
1047 Ok(None)
1048 }
1049
1050 /// End one command: drop everything held that a person can change.
1051 ///
1052 /// A command is one unit of work — one invocation of the binary, or whatever a caller
1053 /// holding one engine for many units of work calls between them. Within a command a
1054 /// source may hold what it read and reuse it; across this call it may not. After it
1055 /// returns, a source answers no read and bases no write on item content, statuses, board
1056 /// contents or search results it held before the call. It may keep only identifiers and
1057 /// vocabulary that stay valid in normal use — a repository's node id, a team's workflow
1058 /// states — and only where a lookup that misses one re-reads rather than refusing.
1059 ///
1060 /// Defaulted to doing nothing, which is right for a source that holds nothing between
1061 /// calls or holds only what the rule above lets it keep. A source that holds more owes
1062 /// an override. Never called by the engine on its own: the binary's one command is its
1063 /// whole process, so only a caller that outlives a command calls it.
1064 ///
1065 /// # Errors
1066 ///
1067 /// Returns a [`SourceError`] when the source could not be sure it dropped what it held —
1068 /// ordinarily a hosted plugin that did not answer. A caller should not go on to the next
1069 /// command on that source's earlier reads.
1070 async fn end_command(&self) -> Result<(), SourceError> {
1071 Ok(())
1072 }
1073}
1074
1075/// The factory that turns one configuration block into a live [`TaskSource`].
1076///
1077/// Having the compile-time registry and the subprocess seam be the same shape is
1078/// the whole reason this is a trait rather than a free function.
1079pub trait SourcePlugin: Send + Sync + 'static {
1080 /// The name a configuration document's `plugin:` field names.
1081 fn kind(&self) -> &'static str;
1082
1083 /// The JSON Schema for this plugin's own `config:` block.
1084 fn config_schema(&self) -> Schema;
1085
1086 /// Build a live source from one configuration block.
1087 ///
1088 /// `name` is the configured source's name, for error messages only — a
1089 /// plugin never learns it for any other purpose.
1090 ///
1091 /// # Errors
1092 ///
1093 /// Returns [`SourceError::Config`] when `config` is not valid for this
1094 /// plugin, or [`SourceError::Auth`] when a named credential is absent.
1095 fn build(
1096 &self,
1097 name: &SourceName,
1098 config: &serde_json::Value,
1099 secrets: &dyn SecretResolver,
1100 ) -> Result<Box<dyn TaskSource>, SourceError>;
1101
1102 /// Build a live source from one configuration block, handing it the clock it paces and
1103 /// backs off on.
1104 ///
1105 /// The binary builds every in-process source through this, with the process's one
1106 /// [`SharedClock`] — [`system_clock`](crate::system_clock) unless a test asked for
1107 /// simulated time. Defaulted to [`build`](Self::build), which ignores the clock: a plugin
1108 /// that never waits needs no edit, and one that does overrides this and routes its waits
1109 /// through `clock`.
1110 ///
1111 /// # Errors
1112 ///
1113 /// As [`build`](Self::build).
1114 fn build_with_clock(
1115 &self,
1116 name: &SourceName,
1117 config: &serde_json::Value,
1118 secrets: &dyn SecretResolver,
1119 clock: SharedClock,
1120 ) -> Result<Box<dyn TaskSource>, SourceError> {
1121 let _ = clock;
1122 self.build(name, config, secrets)
1123 }
1124
1125 /// The fields of this plugin's `config:` block that name a filesystem path, as dotted
1126 /// paths into that block.
1127 ///
1128 /// A relative value at one of these, **supplied by a configuration document**, is
1129 /// resolved against the directory holding that document before [`Self::build`] sees it;
1130 /// supplied through the environment or a flag it keeps resolving against the process
1131 /// working directory, because there is no document to rebase it on. A plugin is handed
1132 /// values and no origins, so this declaration is the only way it can say which of its
1133 /// own fields that rule reaches.
1134 ///
1135 /// Defaulted to none, which is what keeps this an addition rather than a break: a
1136 /// plugin whose block holds no path needs no edit, and a caller asks every plugin
1137 /// rather than keeping a table of which ones answer.
1138 // llmlint: ignore[invalid_states_unrepresentable] The identity of a configuration field
1139 // is a name, and no type can make a wrong one unrepresentable here: every string is a
1140 // syntactically valid dotted path, so a newtype would validate nothing and would only
1141 // move where a name that is not a field of *this* plugin is accepted. What decides that
1142 // is whether the name is a property of the schema `config_schema` publishes — a
1143 // per-plugin fact no shared type can hold — so the gate is per plugin and executable:
1144 // `document_relative_fields_are_fields_this_plugin_declares` in
1145 // `onetaskgraph-local-md/tests/plugin.rs`, which a plugin adding a declaration owes its
1146 // own copy of.
1147 fn document_relative_paths(&self) -> &'static [&'static str] {
1148 &[]
1149 }
1150}
1151
1152/// How a plugin reads the credential its configuration names.
1153///
1154/// A configuration document never carries a credential value, only the name of
1155/// the environment variable holding it.
1156pub trait SecretResolver: Send + Sync {
1157 /// The value of `var`, or `None` when nothing defines it.
1158 ///
1159 /// The returned value is never logged and never appears in `Debug` output.
1160 fn get(&self, var: &str) -> Option<SecretString>;
1161}