onetaskgraph_core/plan.rs
1//! What the engine did, carried back with every response.
2//!
3//! The point of a capability declaration is that two sources answer the same
4//! query differently and both answers are correct. These types make that visible
5//! instead of leaving a user to guess why one source was fast and another was not:
6//! `--explain` renders a [`QueryPlan`] and `--json` carries it as a field.
7
8use onetaskgraph_plugin_api::{SourceError, SourceName};
9use schemars::JsonSchema;
10use serde::{Deserialize, Serialize};
11
12use crate::engine::{Owed, Resumption, StreamState};
13use crate::failure::{FailureClass, classify};
14
15/// One page of engine output, with the plan that produced it.
16#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
17pub struct QueryResponse<T> {
18 /// This page's items, already qualified and merged across sources.
19 pub items: Vec<T>,
20 /// Where to resume, or `None` when every source is exhausted.
21 pub next: Option<PageToken>,
22 /// What each source was asked to do, and what the engine did instead.
23 pub plan: QueryPlan,
24 /// Sources that failed. One failure never fails the whole query.
25 pub errors: Vec<SourceFailure>,
26}
27
28/// What the engine did, per source.
29#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema)]
30pub struct QueryPlan {
31 /// One entry per source the query reached.
32 pub per_source: Vec<SourcePlan>,
33}
34
35/// What one source was asked for, and what happened to each predicate.
36#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
37pub struct SourcePlan {
38 /// The configured source this describes.
39 pub source: SourceName,
40 /// The plugin kind behind it.
41 // 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): `kind: String` is approved contract text. It is also the one field that could not be narrowed even if it were free — a plan must carry the kind a subprocess-hosted plugin reports, an open vocabulary no compile-time type can enumerate.
42 pub kind: String,
43 /// Predicates the source applied itself.
44 ///
45 /// The four predicate vectors below partition one set of outcomes, and nothing in
46 /// the type says so: a `Predicate` could appear in two of them at once, or in none.
47 /// One `Vec<(Predicate, Outcome)>` — or a map keyed by predicate — would make that
48 /// unrepresentable. See the directive below for why it stays as it is.
49 // llmlint: ignore[invalid_states_unrepresentable] SECOND PERMITTED REASON — this
50 // restates at a new site the justification already recorded at
51 // `Capabilities.max_page_size` (capability.rs) and `PageRequest.limit` (query.rs):
52 // `SourcePlan`'s four-vector shape is approved contract text, reproduced field for
53 // field, and `--json` publishes it as the wire format both SDKs are generated from.
54 // Collapsing the four vectors into one outcome-tagged collection is a change to that
55 // contract, which is the contract owner's call and is expressly forbidden to any node
56 // of this plan while other nodes are being written against this text. The finding is
57 // correct and is surfaced as a contract defect rather than dismissed: the contract can
58 // represent a plan its own rules forbid.
59 pub pushed_down: Vec<Predicate>,
60 /// Predicates the engine applied in memory over a wider result set.
61 pub applied_locally: Vec<Predicate>,
62 /// Predicates the engine answered by a bounded scan of the source.
63 pub emulated: Vec<Predicate>,
64 /// Predicates neither side could answer, so the result is unconstrained.
65 ///
66 /// Never [`Predicate::ReverseDependencies`]: `DependencySupport` has no
67 /// unsupported variant, so a reverse-dependency read is answered natively or
68 /// emulated by the engine's bounded scan, never abandoned. The type cannot say
69 /// so — see the directive below.
70 // llmlint: ignore[invalid_states_unrepresentable] SECOND PERMITTED REASON — this
71 // restates at a new site the justification already recorded at
72 // `Capabilities.max_page_size` (capability.rs) and `PageRequest.limit` (query.rs):
73 // `unavailable: Vec<Predicate>` and the `Predicate` enum are both approved contract
74 // text, so a narrower element type here is the contract owner's call, not this
75 // crate's. The finding is correct and is being surfaced as a contract defect rather
76 // than dismissed: the contract can express a state its own rules forbid.
77 pub unavailable: Vec<Predicate>,
78 /// How many pages the engine pulled from this source to answer.
79 pub pages_fetched: u32,
80}
81
82/// One thing a query can ask of a source.
83#[derive(
84 Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
85)]
86#[serde(rename_all = "kebab-case")]
87pub enum Predicate {
88 /// Filter by label name.
89 Label,
90 /// Filter by status category.
91 Status,
92 /// Filter by priority.
93 Priority,
94 /// Filter by comment activity: a comment created or last edited at or after an instant.
95 ///
96 /// Applied locally, it costs a read of the source's comments for every task the source's
97 /// other predicates kept — which is what a plan naming it there is saying.
98 CommentedSince,
99 /// Filter by a caller-defined metadata value.
100 Metadata,
101 /// Filter by copy origin: the qualified id an item was copied from.
102 Origin,
103 /// Search titles.
104 SearchTitle,
105 /// Search bodies.
106 SearchContent,
107 /// Filter by owning project.
108 Project,
109 /// Read the source's documents.
110 ///
111 /// Not a filter, and reported only as [`unavailable`](SourcePlan::unavailable): a
112 /// source declaring it has no documents contributes no document rows and there is
113 /// nothing for the engine to narrow, which is the same shape `Project` takes for a
114 /// source with no project table.
115 Document,
116 /// Walk dependency edges backwards.
117 ReverseDependencies,
118}
119
120/// One source's failure, kept beside the results the other sources returned.
121///
122/// Written with a `class` beside the error, which is not a field here: it is computed by
123/// [`classify`](crate::failure::classify) as the entry is written, so it cannot disagree
124/// with the error it classifies, and a document read back in has it recomputed rather than
125/// trusted.
126#[derive(Debug, Clone, PartialEq, Deserialize)]
127pub struct SourceFailure {
128 /// The source that failed.
129 pub source: SourceName,
130 /// Why.
131 pub error: SourceError,
132}
133
134// A `SourceFailure` as it is written: the failure, and its class. Its doc comment is the
135// schema's description, which is why it is `SourceFailure`'s own sentence.
136/// One source's failure, kept beside the results the other sources returned.
137#[derive(Serialize, JsonSchema)]
138#[schemars(rename = "SourceFailure")]
139struct ClassifiedSourceFailure<'a> {
140 /// The source that failed.
141 source: &'a SourceName,
142 /// Why.
143 error: &'a SourceError,
144 /// Whether repeating the request unchanged could change this source's answer.
145 class: FailureClass,
146}
147
148impl<'a> From<&'a SourceFailure> for ClassifiedSourceFailure<'a> {
149 fn from(failure: &'a SourceFailure) -> Self {
150 Self {
151 source: &failure.source,
152 error: &failure.error,
153 class: classify(Some(&failure.error)),
154 }
155 }
156}
157
158impl Serialize for SourceFailure {
159 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
160 ClassifiedSourceFailure::from(self).serialize(serializer)
161 }
162}
163
164impl JsonSchema for SourceFailure {
165 fn schema_name() -> std::borrow::Cow<'static, str> {
166 ClassifiedSourceFailure::schema_name()
167 }
168
169 fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
170 ClassifiedSourceFailure::json_schema(generator)
171 }
172}
173
174/// The engine's own resume token: one plugin cursor per source stream, opaque to the
175/// caller exactly as a plugin's cursor is opaque to the engine.
176///
177/// Rendered as lower-case hex, which is not obfuscation — the inside is not a secret —
178/// but the one property a token a person copies off a terminal has to have: it survives
179/// a shell. The document underneath holds a plugin's own cursor, and a cursor may hold
180/// anything at all, so a token spelled as the raw JSON would carry quotes, braces and
181/// spaces straight into the next command line. Hex has no character a shell reads.
182///
183/// # What a token is and is not checked for
184///
185/// Both ways in go through [`parse`](Self::parse) — including deserialising one — and
186/// what that establishes is **structural**: the string is hex, the bytes are this
187/// engine's own resume document, and every state in it is well formed. It does not, and
188/// cannot, establish that this engine is the one that wrote it. A token is not a
189/// credential and carries nothing secret; forging one buys a caller nothing they could
190/// not have asked for outright, since every cursor inside is handed straight back to the
191/// source that issued it and is validated there.
192///
193/// What a forged token *could* do is name a stream this configuration has no source for,
194/// or resume further into a page than the engine ever pages. Both are refused where the
195/// token meets the query it is resuming, by
196/// [`Engine`](crate::Engine) — see `EngineError::Token` — because only the engine knows
197/// which sources are configured and what page ceiling each declares.
198#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
199#[serde(try_from = "String", into = "String")]
200pub struct PageToken(String);
201
202impl PageToken {
203 /// Encode where every stream still walking picks up.
204 ///
205 /// Crate-private on purpose: what a token *means* is the engine's, and a caller able
206 /// to build one from parts could name a stream no query addressed. A caller with a
207 /// token in hand reaches it through [`parse`](Self::parse) instead, which checks its
208 /// structure — see this type's own note for what that does and does not establish.
209 ///
210 /// Infallible: a stream state is a source name, a stream kind, an optional cursor
211 /// and a count, and none of those can fail to serialise.
212 ///
213 /// Only ever reached with at least one stream, because a walk with nothing left to
214 /// resume reports no token at all — which is why [`parse`](Self::parse) refuses an
215 /// empty one.
216 pub(crate) fn encode(query: &str, owed: Option<Owed>, streams: &[StreamState]) -> Self {
217 let document = serde_json::to_string(&Resumption {
218 query: query.to_owned(),
219 owed,
220 streams: streams.to_vec(),
221 })
222 .expect("a resumption is plain data and always serialises");
223 Self(to_hex(&document))
224 }
225
226 /// Accept a token from a caller — a `--page` argument, or a deserialised response —
227 /// refusing anything that is not this engine's own resume document.
228 ///
229 /// Structural only, deliberately: see the type's own note for what this establishes
230 /// and what [`Engine`](crate::Engine) checks instead.
231 ///
232 /// # Errors
233 ///
234 /// Returns [`SourceError::Malformed`] when `raw` is not hex, is not this engine's
235 /// document, or holds a state that is not well formed.
236 pub fn parse(raw: impl Into<String>) -> Result<Self, SourceError> {
237 let token = Self(raw.into());
238 token.resumption()?;
239 Ok(token)
240 }
241
242 /// Borrow the underlying token.
243 #[must_use]
244 pub fn as_str(&self) -> &str {
245 &self.0
246 }
247
248 /// Where each stream claims to pick up.
249 ///
250 /// Infallible, and that is a property of the type rather than an assumption: the only
251 /// two ways to obtain a `PageToken` are [`encode`](Self::encode), which built this
252 /// document, and [`parse`](Self::parse), which refuses anything that does not decode
253 /// — and deserialising one goes through `parse`. So a token that does not decode
254 /// never exists to be read here. Whether what it *says* is usable against the query
255 /// being resumed is the engine's to decide, not this type's.
256 pub(crate) fn decode(&self) -> Resumption {
257 self.resumption()
258 .expect("every way to build a PageToken validates it")
259 }
260
261 /// The document inside, or why this is not one of this engine's tokens.
262 fn resumption(&self) -> Result<Resumption, SourceError> {
263 let document = from_hex(&self.0).ok_or_else(|| SourceError::Malformed {
264 message: "that is not a page token this engine writes: it is not even hex".to_owned(),
265 })?;
266 let resumption: Resumption =
267 serde_json::from_str(&document).map_err(|error| SourceError::Malformed {
268 message: format!("that is not a page token this engine writes: {error}"),
269 })?;
270 let streams = &resumption.streams;
271 // A token with nothing to resume is one this engine never writes: `encode` is
272 // reached only while at least one stream still has rows to give, and a walk with
273 // none reports no token at all. Accepting one would answer an empty page and exit
274 // zero, which reads as a walk that ended rather than as the mistake it is.
275 if streams.is_empty() {
276 return Err(SourceError::Malformed {
277 message: "that is not a page token this engine writes: it resumes nothing"
278 .to_owned(),
279 });
280 }
281 Ok(resumption)
282 }
283}
284
285/// Deserialising a token goes through [`PageToken::parse`], so a response carrying
286/// a token this engine never issued is refused where it is read.
287impl TryFrom<String> for PageToken {
288 type Error = SourceError;
289
290 fn try_from(value: String) -> Result<Self, Self::Error> {
291 Self::parse(value)
292 }
293}
294
295/// A token is its string, so serialising one is that string and nothing else — the
296/// checking all happens on the way in, where a caller's input is.
297impl From<PageToken> for String {
298 fn from(value: PageToken) -> Self {
299 value.0
300 }
301}
302
303impl std::fmt::Display for PageToken {
304 /// The opaque string a caller passes back as `--page`.
305 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
306 f.write_str(&self.0)
307 }
308}
309
310/// Render `document` as lower-case hex.
311fn to_hex(document: &str) -> String {
312 let mut rendered = String::with_capacity(document.len() * 2);
313 for byte in document.as_bytes() {
314 rendered.push(nibble(byte >> 4));
315 rendered.push(nibble(byte & 0x0f));
316 }
317 rendered
318}
319
320fn nibble(value: u8) -> char {
321 char::from_digit(u32::from(value), 16).expect("a nibble is a hex digit")
322}
323
324/// Read hex back, or `None` when `raw` is not hex of valid UTF-8.
325fn from_hex(raw: &str) -> Option<String> {
326 if !raw.len().is_multiple_of(2) {
327 return None;
328 }
329 let digits: Vec<u8> = raw
330 .chars()
331 .map(|digit| digit.to_digit(16))
332 .collect::<Option<Vec<u32>>>()?
333 .into_iter()
334 .map(|digit| u8::try_from(digit).expect("a hex digit fits in a byte"))
335 .collect();
336 let bytes: Vec<u8> = digits
337 .chunks(2)
338 .map(|pair| (pair[0] << 4) | pair[1])
339 .collect();
340 String::from_utf8(bytes).ok()
341}