Skip to main content

onetaskgraph_plugin_api/
update.rs

1//! A targeted update of one existing task: every field a caller names, and nothing else.
2//!
3//! The narrow writes each change one field in one call, and a copy rewrites the whole item. A
4//! caller whose change touches a status and several metadata keys at once needs neither: this
5//! names exactly the fields that changed, and a source applies them in as few writes as it
6//! can, sending nothing for a field that already holds the requested value.
7//!
8//! The source's answer carries everything the engine reports about the call — the task read
9//! back, the fields it wrote, and the `delivers` it held before — so the engine reads nothing
10//! of its own around it. That is the difference between one read of an item and two on a
11//! hosted backend, on every update.
12
13use std::collections::{BTreeMap, BTreeSet};
14
15use schemars::JsonSchema;
16use serde::{Deserialize, Serialize};
17use serde_json::Value;
18
19use crate::{
20    DependencyEdge, Direction, ItemWrite, MetadataKey, NativeId, PageRequest, Priority, Repository,
21    SourceError, Status, Task, TaskRef, TaskSource,
22};
23
24/// A targeted update of one existing task. A member left `None` (or empty) leaves that field
25/// exactly as the source holds it.
26///
27/// Labels and project membership are deliberately outside it — an existing item is never
28/// moved, and no caller writes labels this way — and so is creation, which is a copy's. Its
29/// `repositories` list is a field of the item like its title, and is replaced whole.
30#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
31pub struct TaskUpdate {
32    /// The task's title.
33    #[serde(default, skip_serializing_if = "Option::is_none")]
34    pub title: Option<String>,
35    /// `Task::content` exactly as a read reports it; a metadata block the source keeps in the
36    /// same backend field is kept byte for byte.
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub content: Option<String>,
39    /// Name and category. A source that keeps status names stores the name; one that maps by
40    /// category (GitHub Projects) writes its mapped option, exactly as `write_task` does, and
41    /// refuses a category it has disabled in the same words.
42    #[serde(default, skip_serializing_if = "Option::is_none")]
43    pub status: Option<Status>,
44    /// The task's priority; `none` clears it.
45    #[serde(default, skip_serializing_if = "Option::is_none")]
46    pub priority: Option<Priority>,
47    // llmlint: ignore-block[invalid_states_unrepresentable] `metadata_set` and `metadata_remove` are two members because Contract 1 of the `graphql-writeback-quota` project record fixes them by name and type, and onepipeline builds against exactly this shape; the one state they can express that is not an update — one key in both — is refused by `consistent` before any source is read or written, in one wording every source and the engine share. A single map of key to set-or-remove would be the contract's owner's change to make, not this crate's.
48    /// Keys added or replaced; every other key is kept.
49    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
50    #[schemars(!skip_serializing_if)]
51    pub metadata_set: BTreeMap<MetadataKey, Value>,
52    /// Keys removed; a key the task does not hold is no write. Refused when it names a key
53    /// `metadata_set` also names.
54    #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
55    #[schemars(!skip_serializing_if)]
56    pub metadata_remove: BTreeSet<MetadataKey>,
57    // llmlint: ignore-end[invalid_states_unrepresentable]
58    /// Replaces the list; the engine keeps each ticket's `delivered_by` in step.
59    #[serde(default, skip_serializing_if = "Option::is_none")]
60    pub delivers: Option<Vec<TaskRef>>,
61    /// Replaces the task's forward dependency edges (`from` is this task); the source sends
62    /// only the difference.
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    pub depends_on: Option<Vec<DependencyEdge>>,
65    /// Replaces the task's [`Task::repositories`] whole; an empty list clears it. Where the
66    /// item's issue lives is never moved by it: it is the list the item names, as a read
67    /// reports it.
68    #[serde(default, skip_serializing_if = "Option::is_none")]
69    pub repositories: Option<Vec<Repository>>,
70}
71
72impl TaskUpdate {
73    /// Whether this update names no field at all.
74    #[must_use]
75    pub fn is_empty(&self) -> bool {
76        self.title.is_none()
77            && self.content.is_none()
78            && self.status.is_none()
79            && self.priority.is_none()
80            && self.metadata_set.is_empty()
81            && self.metadata_remove.is_empty()
82            && self.delivers.is_none()
83            && self.depends_on.is_none()
84            && self.repositories.is_none()
85    }
86
87    /// Whether this update names `field`, which is the only way a source may report it written.
88    #[must_use]
89    pub fn names(&self, field: UpdatedField) -> bool {
90        match field {
91            UpdatedField::Title => self.title.is_some(),
92            UpdatedField::Content => self.content.is_some(),
93            UpdatedField::Status => self.status.is_some(),
94            UpdatedField::Priority => self.priority.is_some(),
95            UpdatedField::Metadata => {
96                !self.metadata_set.is_empty() || !self.metadata_remove.is_empty()
97            }
98            UpdatedField::Delivers => self.delivers.is_some(),
99            UpdatedField::DependsOn => self.depends_on.is_some(),
100            UpdatedField::Repositories => self.repositories.is_some(),
101        }
102    }
103
104    /// Every metadata key this update both sets and removes, in order.
105    ///
106    /// An update naming one is refused before anything is written: which of the two it meant
107    /// is not something a source may guess.
108    #[must_use]
109    pub fn contradictions(&self) -> Vec<&MetadataKey> {
110        self.metadata_remove
111            .iter()
112            .filter(|key| self.metadata_set.contains_key(*key))
113            .collect()
114    }
115
116    /// The refusal of an update that both sets and removes a key, or `Ok` when it does not.
117    ///
118    /// Spelled once here, so every source and the engine refuse it in the same words.
119    ///
120    /// # Errors
121    ///
122    /// Returns [`SourceError::Refused`] naming every such key.
123    pub fn consistent(&self) -> Result<(), SourceError> {
124        let both = self.contradictions();
125        if both.is_empty() {
126            return Ok(());
127        }
128        let named: Vec<&str> = both.iter().map(|key| key.as_str()).collect();
129        Err(SourceError::Refused {
130            message: format!(
131                "this update both sets and removes the metadata {} {}; next: name each key \
132                 either to set or to remove, not both",
133                if named.len() == 1 { "key" } else { "keys" },
134                named.join(", ")
135            ),
136        })
137    }
138
139    /// `task` as it reads once this update is applied to it: every named field replaced, every
140    /// named metadata key set or removed, and everything else exactly as it was.
141    ///
142    /// [`depends_on`](Self::depends_on) is not a member of a [`Task`], so it is not applied
143    /// here.
144    #[must_use]
145    pub fn applied_to(&self, task: &Task) -> Task {
146        let mut updated = task.clone();
147        if let Some(title) = &self.title {
148            updated.title.clone_from(title);
149        }
150        if let Some(content) = &self.content {
151            updated.content = Some(content.clone());
152        }
153        if let Some(status) = &self.status {
154            updated.status = status.clone();
155        }
156        if let Some(priority) = self.priority {
157            updated.priority = priority;
158        }
159        for (key, value) in &self.metadata_set {
160            updated
161                .metadata
162                .insert(key.as_str().to_owned(), value.clone());
163        }
164        for key in &self.metadata_remove {
165            updated.metadata.remove(key.as_str());
166        }
167        if let Some(delivers) = &self.delivers {
168            updated.delivers.clone_from(delivers);
169        }
170        if let Some(repositories) = &self.repositories {
171            updated.repositories.clone_from(repositories);
172        }
173        updated
174    }
175
176    /// The fields this update names whose value differs between `before` and `after` — which,
177    /// for a source that compares before it writes, is exactly what it wrote.
178    ///
179    /// [`UpdatedField::DependsOn`] is never among them: a [`Task`] carries no edges, so a
180    /// source says whether it wrote those itself.
181    #[must_use]
182    pub fn changed(&self, before: &Task, after: &Task) -> BTreeSet<UpdatedField> {
183        let mut written = BTreeSet::new();
184        if self.title.is_some() && before.title != after.title {
185            written.insert(UpdatedField::Title);
186        }
187        if self.content.is_some() && before.content != after.content {
188            written.insert(UpdatedField::Content);
189        }
190        if self.status.is_some() && before.status != after.status {
191            written.insert(UpdatedField::Status);
192        }
193        if self.priority.is_some() && before.priority != after.priority {
194            written.insert(UpdatedField::Priority);
195        }
196        let named = self
197            .metadata_set
198            .keys()
199            .chain(&self.metadata_remove)
200            .map(MetadataKey::as_str);
201        if named
202            .into_iter()
203            .any(|key| before.metadata.get(key) != after.metadata.get(key))
204        {
205            written.insert(UpdatedField::Metadata);
206        }
207        if self.delivers.is_some() && before.delivers != after.delivers {
208            written.insert(UpdatedField::Delivers);
209        }
210        if self.repositories.is_some() && before.repositories != after.repositories {
211            written.insert(UpdatedField::Repositories);
212        }
213        written
214    }
215
216    /// Apply this update to the task `id` of `source` by rewriting it: read it, apply the
217    /// update, and — only when anything differs — [`write_task`](TaskSource::write_task) it
218    /// with its `target` set, then read it back. `None` when `source` holds no such task.
219    ///
220    /// This is what [`TaskSource::update_task`] does for a source that does not override it,
221    /// and it is public so a host forwarding the call to a peer that does not declare the
222    /// targeted update can fall back to exactly it. It is correct and not minimal: a write it
223    /// makes rewrites the whole item, and a source that can send less owes an override.
224    ///
225    /// The forward edges are read whenever they are needed — to compare against a named
226    /// `depends_on`, or to carry unchanged through a rewrite a named `depends_on` does not
227    /// replace — because a [`write_task`](TaskSource::write_task) replaces them.
228    ///
229    /// # Errors
230    ///
231    /// Returns [`SourceError::Refused`] for an update that both sets and removes a key,
232    /// [`SourceError::Malformed`] when the write answers an id other than `id` or the task could
233    /// not be read back after it, and
234    /// whatever the source's own read or write fails with.
235    pub async fn rewrite<S: TaskSource + ?Sized>(
236        &self,
237        source: &S,
238        id: &NativeId,
239    ) -> Result<Option<TaskUpdateOutcome>, SourceError> {
240        self.consistent()?;
241        let Some(held) = source.get_task(id).await? else {
242            return Ok(None);
243        };
244        let updated = self.applied_to(&held);
245        let (depends_on, edges_differ) = match &self.depends_on {
246            Some(wanted) => {
247                let current = forward_edges(source, id).await?;
248                (wanted.clone(), !same_edges(&current, wanted))
249            }
250            None if updated != held => (forward_edges(source, id).await?, false),
251            None => (Vec::new(), false),
252        };
253        if updated == held && !edges_differ {
254            return Ok(Some(TaskUpdateOutcome {
255                delivers_before: held.delivers.clone(),
256                task: held,
257                written: BTreeSet::new(),
258            }));
259        }
260        let wrote = source
261            .write_task(&ItemWrite {
262                target: Some(id.clone()),
263                item: updated,
264                depends_on,
265            })
266            .await?;
267        // A write that names its target answers that target: another id means the source
268        // wrote some other record, and reading `id` back would report an update that never
269        // reached it.
270        if &wrote != id {
271            return Err(SourceError::Malformed {
272                message: format!(
273                    "task {id} was updated, and the source answered that it wrote {wrote}"
274                ),
275            });
276        }
277        let task = source
278            .get_task(id)
279            .await?
280            .ok_or_else(|| SourceError::Malformed {
281                message: format!("task {id} was updated and then could not be read back"),
282            })?;
283        let mut written = self.changed(&held, &task);
284        if edges_differ {
285            written.insert(UpdatedField::DependsOn);
286        }
287        Ok(Some(TaskUpdateOutcome {
288            task,
289            written,
290            delivers_before: held.delivers,
291        }))
292    }
293}
294
295/// One field of a task a [`TaskUpdate`] names, as the answer to it reports what was written.
296///
297/// kebab-case on the wire. [`Metadata`](Self::Metadata) stands for every metadata key the
298/// update set or removed together; [`DependsOn`](Self::DependsOn) for the task's forward edges.
299#[derive(
300    Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
301)]
302#[serde(rename_all = "kebab-case")]
303pub enum UpdatedField {
304    /// [`TaskUpdate::title`].
305    Title,
306    /// [`TaskUpdate::content`].
307    Content,
308    /// [`TaskUpdate::status`].
309    Status,
310    /// [`TaskUpdate::priority`].
311    Priority,
312    /// [`TaskUpdate::metadata_set`] and [`TaskUpdate::metadata_remove`].
313    Metadata,
314    /// [`TaskUpdate::delivers`].
315    Delivers,
316    /// [`TaskUpdate::depends_on`].
317    DependsOn,
318    /// [`TaskUpdate::repositories`].
319    Repositories,
320}
321
322/// What a source answers a [`TaskUpdate`] of a task it holds with.
323///
324/// Everything the engine reports about the call is read off this, so the engine sends no read
325/// of its own before or after it: `task` is what it answers with, `written` is what it says
326/// was written, and `delivers_before` is what tells it which delivered tasks the update
327/// dropped.
328#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
329pub struct TaskUpdateOutcome {
330    /// The task as this source reads it once the update landed.
331    pub task: Task,
332    /// The fields this source actually wrote — empty when nothing differed, and never a field
333    /// the update did not name. Required on the wire: an answer that leaves it out has not said
334    /// what it wrote, which is not the same as having written nothing.
335    pub written: BTreeSet<UpdatedField>,
336    /// The task's [`Task::delivers`] as this source held it before the update, which is the
337    /// list a named `delivers` replaced. Required on the wire, for the reason `written` is: an
338    /// answer leaving it out would hide every delivered task the update dropped.
339    pub delivers_before: Vec<TaskRef>,
340}
341
342/// Every forward dependency edge `source` reports at `id`, walked to exhaustion.
343async fn forward_edges<S: TaskSource + ?Sized>(
344    source: &S,
345    id: &NativeId,
346) -> Result<Vec<DependencyEdge>, SourceError> {
347    let limit = source.capabilities().max_page_size.max(1);
348    let mut edges = Vec::new();
349    let mut cursor = None;
350    loop {
351        let page = source
352            .task_dependencies(id, Direction::DependsOn, &PageRequest { cursor, limit })
353            .await?;
354        edges.extend(page.items);
355        match page.next {
356            Some(next) => cursor = Some(next),
357            None => return Ok(edges),
358        }
359    }
360}
361
362/// Whether two edge lists name the same far ends by the same kinds, whatever their order and
363/// whatever each says its near end is.
364fn same_edges(held: &[DependencyEdge], wanted: &[DependencyEdge]) -> bool {
365    let far = |edges: &[DependencyEdge]| -> BTreeSet<(String, String, String)> {
366        edges
367            .iter()
368            .map(|edge| {
369                (
370                    edge.to.id().to_owned(),
371                    format!("{:?}", edge.to.kind),
372                    format!("{:?}", edge.kind),
373                )
374            })
375            .collect()
376    };
377    held.len() == wanted.len() && far(held) == far(wanted)
378}