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