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}