Skip to main content

onetaskgraph_core/
resolve.rs

1//! Turning a configuration into live sources.
2//!
3//! Two steps, deliberately separable. [`validate_sources`] runs at load, for every
4//! verb, and refuses a source whose `config:` block does not match the schema its
5//! plugin declares — the plugin itself is already one this build has, because
6//! [`SourceConfig::plugin`] is a [`PluginKind`](crate::PluginKind) and no other kind
7//! can be represented. [`resolve`] then
8//! builds the sources a command actually needs. The order is the point: a typo in a
9//! per-source field is refused while the user is still looking at the file that
10//! caused it, rather than surfacing as a confusing failure inside the first HTTP
11//! call that source makes.
12
13use std::fmt;
14use std::sync::Arc;
15
16use jsonschema::error::ValidationErrorKind;
17use onetaskgraph_plugin_api::{
18    SecretResolver, SharedClock, SourceError, SourceName, SourcePlugin, StatusMapping, TaskSource,
19    system_clock,
20};
21use serde_json::Value;
22
23use crate::PluginKind;
24use crate::config::{Config, ConfigError, SourceConfig};
25use crate::plan::SourceFailure;
26use crate::subprocess::SubprocessPlugin;
27
28/// One configured source, built and ready to answer.
29///
30/// Held behind its accessors, and constructible only by [`resolve`], because `kind` is a
31/// claim about `source` rather than a value beside it: a caller that could write the two
32/// independently could say `linear` over a source that reports `local-md`, and the plan a
33/// query reports names the kind. Building it where the plugin builds the source is what
34/// makes the pair an invariant instead of something every reader has to re-check.
35pub struct ResolvedSource {
36    name: SourceName,
37    source: Box<dyn TaskSource>,
38}
39
40impl ResolvedSource {
41    /// Adopt a source under `name`.
42    ///
43    /// The kind is not a second field a caller could set: it is read back off the source
44    /// through [`TaskSource::kind`], so the pair cannot disagree and the plan a query
45    /// reports names the kind the source itself claims. That also makes this the seam a
46    /// source built outside the registry arrives through — the engine's own tests today,
47    /// the subprocess-hosted plugins the protocol document describes later.
48    #[must_use]
49    pub fn adopt(name: SourceName, source: Box<dyn TaskSource>) -> Self {
50        Self { name, source }
51    }
52
53    /// The name the configuration gave it, which qualifies every id it returns.
54    #[must_use]
55    pub fn name(&self) -> &SourceName {
56        &self.name
57    }
58
59    /// The plugin kind that built it, as the source itself reports it.
60    ///
61    /// A `&str` rather than a [`PluginKind`](crate::PluginKind): a subprocess-hosted
62    /// plugin reports a kind no compile-time enumeration can hold, which is also why
63    /// [`SourcePlan::kind`](crate::SourcePlan::kind) is a `String`.
64    #[must_use]
65    pub fn kind(&self) -> &str {
66        self.source.kind()
67    }
68
69    /// The source itself.
70    #[must_use]
71    pub fn source(&self) -> &dyn TaskSource {
72        self.source.as_ref()
73    }
74}
75
76/// One configured source that could not be built at all.
77///
78/// A missing credential, a plugin whose implementation has not landed, a `config:` block
79/// its own plugin refuses at build time: none of them is a reason to answer nothing for
80/// the *other* sources, so this is carried beside the ones that built and reported as a
81/// [`SourceFailure`] in every response.
82#[derive(Debug, Clone, PartialEq)]
83pub struct UnavailableSource {
84    name: SourceName,
85    /// The plugin kind that was asked to build it.
86    ///
87    /// The kind a plugin reports, which is an open vocabulary rather than an
88    /// under-modelled one: a subprocess-hosted plugin reports a kind arriving over the
89    /// wire from a binary this workspace never compiled, so no compile-time type can
90    /// enumerate it, and a newtype over the same string would only move where an
91    /// unrelated value is accepted. This is the same field, and the same reason, as
92    /// `SourcePlan.kind` and `SourceListing.kind`, where the contract fixes it as a
93    /// string outright.
94    // llmlint: ignore[invalid_states_unrepresentable] the reason above, recorded a third
95    // time because this is the third site the same field appears at: `kind` is what
96    // `SourcePlan.kind` (plan.rs) and `SourceListing.kind` (engine/mod.rs) already carry
97    // as approved contract text, and narrowing it here alone would only make this crate
98    // disagree with the two documents it renders into.
99    kind: &'static str,
100    error: SourceError,
101}
102
103impl UnavailableSource {
104    /// The name the configuration gave it.
105    #[must_use]
106    pub fn name(&self) -> &SourceName {
107        &self.name
108    }
109
110    /// The plugin kind that was asked to build it.
111    #[must_use]
112    pub fn kind(&self) -> &str {
113        self.kind
114    }
115
116    /// Why it did not build.
117    #[must_use]
118    pub fn error(&self) -> &SourceError {
119        &self.error
120    }
121
122    /// The same thing, as a response carries it.
123    #[must_use]
124    pub fn failure(&self) -> SourceFailure {
125        SourceFailure {
126            source: self.name.clone(),
127            error: self.error.clone(),
128        }
129    }
130}
131
132impl fmt::Debug for ResolvedSource {
133    /// Name and kind, the kind spelled the way a configuration spells it rather than
134    /// the way Rust spells the variant. A live source has no meaningful `Debug` of its
135    /// own, and one that did would be a rendering of a user's work — which nothing
136    /// outside the plugin may hold.
137    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
138        f.debug_struct("ResolvedSource")
139            .field("name", &self.name)
140            .field("kind", &self.kind())
141            .finish_non_exhaustive()
142    }
143}
144
145/// Check every configured source without building any of them.
146///
147/// # Errors
148///
149/// Returns [`ConfigError::Setting`] naming `sources.<name>.config...` for a block that
150/// does not match its plugin's declared schema.
151pub fn validate_sources(config: &Config) -> Result<(), ConfigError> {
152    for (name, source) in config.sources() {
153        checked_plugin(name, source)?;
154    }
155    Ok(())
156}
157
158/// Build every configured source, in name order.
159///
160/// The order is the map's, so two runs over one configuration produce the same
161/// sources in the same sequence — which is what makes a multi-source result stable
162/// enough to page through.
163///
164/// # Errors
165///
166/// Returns what [`validate_sources`] returns, and [`ConfigError::Setting`] naming
167/// `sources.<name>` when the plugin itself refuses to build the source.
168pub fn resolve(
169    config: &Config,
170    secrets: &dyn SecretResolver,
171) -> Result<Vec<ResolvedSource>, ConfigError> {
172    validate_sources(config)?;
173    let (built, unavailable) = resolve_available(config, secrets);
174    match unavailable.first() {
175        None => Ok(built),
176        Some(failed) => Err(ConfigError::setting(
177            format!("sources.{}", failed.name()),
178            failed.error().to_string(),
179            format!(
180                "correct that source's configuration, or remove it — `onetaskgraph \
181                 config show` reports every setting under `sources.{}` and the layer it \
182                 came from.",
183                failed.name()
184            ),
185        )),
186    }
187}
188
189/// Build every configured source, keeping the ones that refused beside the ones that
190/// built.
191///
192/// This is what the engine resolves through, and the difference from [`resolve`] is the
193/// whole point: one source with an expired token must not stop the other two from
194/// answering. A refusal here is reported per source, exactly as a source that fails
195/// mid-query is.
196///
197/// The `config:` blocks are not re-checked, because a [`Config`] cannot exist holding one
198/// its own plugin would refuse — [`Config::from_document`](crate::Config::from_document)
199/// checks every block against its plugin's declared schema on the way in.
200#[must_use]
201pub fn resolve_available(
202    config: &Config,
203    secrets: &dyn SecretResolver,
204) -> (Vec<ResolvedSource>, Vec<UnavailableSource>) {
205    resolve_available_with_clock(config, secrets, &system_clock())
206}
207
208/// [`resolve_available`], building every in-process source through
209/// [`SourcePlugin::build_with_clock`] with `clock`, the clock it paces and backs off on.
210///
211/// A `subprocess` source is not handed it: the program behind the pipe keeps its own time.
212#[must_use]
213pub fn resolve_available_with_clock(
214    config: &Config,
215    secrets: &dyn SecretResolver,
216    clock: &SharedClock,
217) -> (Vec<ResolvedSource>, Vec<UnavailableSource>) {
218    let mut built = Vec::new();
219    let mut unavailable = Vec::new();
220    for (name, source) in config.sources() {
221        let plugin = source.plugin().plugin();
222        // The one plugin that passes an origin on: the trait hands a plugin values and no
223        // origins, so a `subprocess` source is built with the directory of the document
224        // its settings came from, which its child measures declared paths from.
225        let outcome = if source.plugin() == PluginKind::Subprocess {
226            SubprocessPlugin.build_from_document(
227                name,
228                source.config(),
229                secrets,
230                source.document_dir(),
231            )
232        } else {
233            // llmlint: ignore[changed_behavior_has_e2e] No plugin of this build waits on the
234            // clock it is handed yet — routing the hosted plugins' pacing through it is the
235            // work this seam exists for — so nothing a source does can show which clock it was
236            // built with. What is observable is proven: the binary settles one clock and
237            // attaches to a test's coordinator with it (`crates/onetaskgraph-e2e/tests/e2e/clock.rs`), and every
238            // built-in plugin builds through this call in every journey.
239            plugin.build_with_clock(name, source.config(), secrets, Arc::clone(clock))
240        };
241        match outcome {
242            Ok(source) => built.push(ResolvedSource::adopt(name.clone(), source)),
243            Err(error) => unavailable.push(UnavailableSource {
244                name: name.clone(),
245                kind: plugin.kind(),
246                error,
247            }),
248        }
249    }
250    (built, unavailable)
251}
252
253/// The plugin this source names, with its `config:` block already checked.
254fn checked_plugin(
255    name: &SourceName,
256    source: &SourceConfig,
257) -> Result<Box<dyn SourcePlugin>, ConfigError> {
258    let plugin = source.plugin().plugin();
259    check_block(name, source.config(), plugin.as_ref())?;
260    Ok(plugin)
261}
262
263/// Check one source's `config:` block against the schema its plugin declares.
264fn check_block(
265    name: &SourceName,
266    block: &Value,
267    plugin: &dyn SourcePlugin,
268) -> Result<(), ConfigError> {
269    // A plugin's own schema is this build's, not a user's, so a schema that will not
270    // compile is a defect in this binary rather than something a user did. It is still
271    // reported rather than panicked on: a user whose one broken source is a plugin they
272    // do not use can drop that source and carry on, which a panic would not let them do.
273    // `every_registered_plugin_declares_a_schema_that_compiles_and_accepts_a_valid_block`
274    // is what keeps it from reaching anybody in the first place.
275    let schema = plugin.config_schema();
276    let validator = jsonschema::validator_for(schema.as_value()).map_err(|error| {
277        ConfigError::setting(
278            format!("sources.{name}.plugin"),
279            format!(
280                "the `{}` plugin declares a configuration schema this build cannot \
281                 compile: {error}",
282                plugin.kind()
283            ),
284            "that is a defect in this binary rather than in your configuration — please \
285             report it, naming the plugin above. Removing that source lets the rest of \
286             this configuration run in the meantime.",
287        )
288    })?;
289
290    let Some(problem) = validator.iter_errors(block).next() else {
291        return Ok(());
292    };
293
294    // A plugin whose source is not written yet declares a schema with no properties at
295    // all, which forbids every field — and a validator has nothing to say about that
296    // beyond "false schema does not allow 7", which names neither the field nor the
297    // reason. Both are worth saying plainly.
298    if schema.as_value().get("properties").is_none()
299        && let Some(fields) = block.as_object()
300        && let Some(first) = fields.keys().next()
301    {
302        return Err(ConfigError::setting(
303            format!("sources.{name}.config.{first}"),
304            format!(
305                "the `{}` plugin declares no configuration fields, so its `config:` block \
306                 must be empty or absent; this one sets {}",
307                plugin.kind(),
308                fields.keys().cloned().collect::<Vec<_>>().join(", ")
309            ),
310            format!(
311                "remove those fields — `onetaskgraph schema` prints what this plugin \
312                 accepts under `plugin_config.{}`.",
313                plugin.kind()
314            ),
315        ));
316    }
317
318    // A `status_mapping` is one grammar every plugin that names its statuses shares, and a
319    // validator can only say a value matched none of its forms — `{}` is "not valid under any
320    // of the schemas listed in the 'anyOf' keyword". The grammar's own reading says which part
321    // is wrong and what to write instead, so a refusal inside one is put in its words.
322    let pointer = problem.instance_path().to_string();
323    if (pointer == "/status_mapping" || pointer.starts_with("/status_mapping/"))
324        && let Some(mapping) = block.get("status_mapping")
325        && let Err(error) = serde_json::from_value::<StatusMapping>(mapping.clone())
326    {
327        return Err(ConfigError::setting(
328            format!("sources.{name}.config.status_mapping"),
329            error.to_string(),
330            "write each category as a name, as null, or as an object naming a `task`, a \
331             `project` or both — `onetaskgraph schema` prints the grammar under \
332             `roots.StatusMapping`.",
333        ));
334    }
335
336    // A validator reports an unexpected field against the *object* that holds it, so
337    // the path alone would name the block and leave the user to find the field inside
338    // the message. The field is the whole of what they have to go and fix, so it is
339    // lifted into the key.
340    let pointer = pointer.replace('/', ".");
341    let unexpected = match problem.kind() {
342        ValidationErrorKind::AdditionalProperties { unexpected } => unexpected.first(),
343        _ => None,
344    };
345    let key = match unexpected {
346        Some(field) => format!("sources.{name}.config{pointer}.{field}"),
347        None => format!("sources.{name}.config{pointer}"),
348    };
349    Err(ConfigError::setting(
350        key,
351        problem.to_string(),
352        format!(
353            "check that field against the `{}` plugin's schema — `onetaskgraph schema` \
354             prints it under `plugin_config.{}`.",
355            plugin.kind(),
356            plugin.kind()
357        ),
358    ))
359}