//! The shared event-filter grammar, and this library's two uses of it.
//!
//! One grammar across the stack — `{include, exclude}` over `source`, a `kind`
//! glob on the kebab-case wire string, and the reserved labels `run_id`, `node`,
//! `step`, `member`, `persona`. Like the [envelope](crate::event::Envelope)
//! beside it, the filter and its matcher are `onemessagebus-agent`'s, re-exported
//! here at the paths this crate has always published them at, and
//! `tests/contract.rs` drives the grammar committed in `docs/contract.md` through
//! them. What stays this crate's is its policy: the launch config, the block
//! naming each source's filter and the read-time profiles, and the profiles it
//! ships.
//!
//! `onepipeline` uses it twice, and the two are not the same thing:
//!
//! - **Source filters** ([`Filters::agentgraph`], [`Filters::vcs`]) are passed
//! through to the libraries that own those streams — `oneagentgraph`'s
//! `--event-filter` and `onevcs`'s filtered `EventStream` — so a run stops
//! paying to relay events nobody will read. They decide what enters the run's
//! merged store, and they are declared once, at launch.
//! - **Read-time profiles** ([`Filters::profiles`]) shape what one reader is
//! shown. They never touch the store, so two readers of the same run see the
//! same events differently and neither loses any.
use std::collections::BTreeMap;
use std::num::NonZeroU64;
use std::path::Path;
use serde::{Deserialize, Deserializer, Serialize};
use crate::error::{Error, Result};
use crate::event::Source;
pub use onemessagebus_agent::event::{EventFilter, Matcher};
/// The profile `next` and `monitor` read through when a caller names none.
pub const DEFAULT_PROFILE: &str = "planner";
/// The profile that shows the detailed activity the default one leaves out:
/// the whole merged stream, which is what an observer of a run reads.
pub const DETAILED_PROFILE: &str = "detailed";
/// The launch-config schema version this build **writes**.
///
/// **9** since a launch names the pool-maintenance schedule its idle driver
/// sweeps on: `maintenance_config` is a key versions 1 to 8 never had, so a
/// document carrying it is a different document and says so. **8** named the
/// command run before every node dispatch to refresh that child's environment:
/// `dispatch_env_hook` and `dispatch_env_hook_timeout`. **7** declared the
/// `onemessagebus` configuration a run's channel is kept under and the bar its
/// envelope reviewer judges against — `bus_config` and `envelope_reviewer_bar` —
/// and **6** the commands a run fires when it ends — `success_hook`,
/// `failure_hook` and `hook_timeout`.
pub const LAUNCH_CONFIG_SCHEMA_VERSION: u32 = 9;
/// Every launch-config version this build **reads**, newest first.
///
/// The same rule the plan schema is read by, and for the same reason: a config
/// is a file an operator wrote at a version, and what each version added is
/// keyed to the version the document declares. An earlier config is a complete
/// document — a version-1 one says nothing about drafting, a version-2 one says
/// nothing about validating a node, a version-3 one says nothing about
/// reviewing an envelope, a version-4 one says nothing about the write-back's
/// budget, a version-5 one says nothing about a run-end hook, a version-6 one
/// says nothing about the bus or a reviewer's bar, a version-7 one says nothing
/// about a dispatch-env hook, and a version-8 one says nothing about a
/// maintenance schedule, which is what a launch naming none of them means — and
/// naming a later key there is refused by that field's name**, exactly as a key
/// no version ever had is.
pub const LAUNCH_CONFIG_SCHEMA_VERSIONS_READ: [u32; 9] =
[LAUNCH_CONFIG_SCHEMA_VERSION, 8, 7, 6, 5, 4, 3, 2, 1];
/// Each key younger than the schema itself: the version it arrived at, and
/// whether a blank value is refused.
///
/// A table rather than a comparison against [`LAUNCH_CONFIG_SCHEMA_VERSION`]:
/// asked that way, every earlier key becomes refused the moment the schema
/// version moves again, and a version-2 config naming the drafting graph
/// version 2 introduced would start being turned down by the bump that added an
/// unrelated key.
///
/// The blank rule is **per key and not per schema**, for the same reason. It is
/// the two hook keys' and the budget's: each was refused-when-blank from the
/// version it arrived at, so no config on disk carries a blank one and refusing
/// it costs nobody a launch that used to work. `pr_author_graph` has shipped since version 2 and a document
/// already written may carry a blank one; whatever that meant then it goes on
/// meaning, because a build that started refusing it would break a config over
/// a key the operator did not change. What it means is settled where the value
/// is *read* rather than here: `driver::start` reads a blank drafting graph as
/// naming none, which is what the document omitting the key says.
///
/// The two run-end hook commands are kept blank from the version they arrived
/// at, deliberately unlike the validator and the reviewer: the contract states a
/// blank hook as this launch saying it has none, so `driver::start` reads it the
/// way it reads a blank drafting graph. Their timeout is a number, and a blank
/// number is the half-written decision the budget's is. The dispatch-env hook
/// and its timeout are read on exactly those two terms, and so is the
/// maintenance schedule: the contract states a blank value, flag or key, as this
/// launch saying it has none.
const KEYS_BY_VERSION: &[(&str, u32, BlankValue)] = &[
("pr_author_graph", 2, BlankValue::Kept),
("node_validator", 3, BlankValue::Refused),
("envelope_reviewer", 4, BlankValue::Refused),
(WRITEBACK_ITEM_BUDGET_KEY, 5, BlankValue::Refused),
("success_hook", 6, BlankValue::Kept),
("failure_hook", 6, BlankValue::Kept),
(HOOK_TIMEOUT_KEY, 6, BlankValue::Refused),
("envelope_reviewer_bar", 7, BlankValue::Refused),
("bus_config", 7, BlankValue::Refused),
("dispatch_env_hook", 8, BlankValue::Kept),
(DISPATCH_ENV_HOOK_TIMEOUT_KEY, 8, BlankValue::Refused),
(crate::maintenance::KEY, 9, BlankValue::Kept),
];
/// The launch-config key naming the write-back's per-item budget, spelled once
/// for the two readers that refuse by it.
const WRITEBACK_ITEM_BUDGET_KEY: &str = "writeback_item_budget";
/// The launch-config key naming how long a run-end hook is awaited, spelled
/// once for the two readers that refuse by it.
const HOOK_TIMEOUT_KEY: &str = "hook_timeout";
/// The launch-config key naming how long the dispatch-env hook is awaited,
/// spelled once for the two readers that refuse by it.
const DISPATCH_ENV_HOOK_TIMEOUT_KEY: &str = "dispatch_env_hook_timeout";
/// How a document carries one of the keys [`KEYS_BY_VERSION`] names.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Carried {
Absent,
Blank,
Named,
}
impl Carried {
fn text(value: Option<&str>) -> Self {
match value {
None => Self::Absent,
Some(text) if text.trim().is_empty() => Self::Blank,
Some(_) => Self::Named,
}
}
}
/// The refusal for a key present and holding nothing, by the key's own name.
///
/// A decision half-written: it reads as "this launch names one" everywhere
/// downstream. One sentence, whichever reader turns it down.
fn refused_blank(key: &str) -> String {
format!(
"`{key}` is present and names nothing — give it a value, or leave the key out to \
declare that this launch has none"
)
}
/// Read `writeback_item_budget` as the positive whole number of seconds it is.
///
/// Serde's own reading of a `u64` would take the key present and holding
/// nothing — `writeback_item_budget:` — as the document omitting it, which is the
/// half-written decision every other refused-when-blank key is turned down for,
/// and would accept zero, which is no budget at all. Both are refused here by the
/// key's name**, where the value is read, because a number has no blank for
/// [`LaunchConfig::load`]'s loop to see; the blank's sentence is the one that
/// loop uses, and zero's is the one the flag and the variable use.
fn item_budget<'de, D: Deserializer<'de>>(
deserializer: D,
) -> std::result::Result<Option<NonZeroU64>, D::Error> {
deserializer.deserialize_any(PositiveSeconds {
key: WRITEBACK_ITEM_BUDGET_KEY,
unit: "seconds per item",
default: crate::cli::DEFAULT_WRITEBACK_ITEM_BUDGET_SECONDS,
zero: crate::writeback::refused_zero_budget,
})
}
/// Read `hook_timeout` as the positive whole number of seconds it is.
///
/// On [`item_budget`]'s terms and for its reasons: serde's own reading would
/// take the key present and blank as omitted, and would accept zero — a timeout
/// that ends every hook before it has begun. Both are refused by the key's name.
fn hook_timeout<'de, D: Deserializer<'de>>(
deserializer: D,
) -> std::result::Result<Option<NonZeroU64>, D::Error> {
deserializer.deserialize_any(PositiveSeconds {
key: HOOK_TIMEOUT_KEY,
unit: "seconds",
default: crate::cli::DEFAULT_HOOK_TIMEOUT_SECONDS,
zero: crate::hooks::refused_zero_timeout,
})
}
/// Read `dispatch_env_hook_timeout` as the positive whole number of seconds it
/// is, on [`hook_timeout`]'s terms and for its reasons.
fn dispatch_env_hook_timeout<'de, D: Deserializer<'de>>(
deserializer: D,
) -> std::result::Result<Option<NonZeroU64>, D::Error> {
deserializer.deserialize_any(PositiveSeconds {
key: DISPATCH_ENV_HOOK_TIMEOUT_KEY,
unit: "seconds",
default: crate::cli::DEFAULT_DISPATCH_ENV_HOOK_TIMEOUT_SECONDS,
zero: crate::dispatchenv::refused_zero_timeout,
})
}
/// A launch-config key read as a positive whole number of seconds, refused by
/// its own name wherever it is anything else.
///
/// One reading for every such key, so a budget and a timeout cannot come to
/// refuse the same mistake in two different sentences.
struct PositiveSeconds {
/// The key, as the refusal names it.
key: &'static str,
/// What one of the seconds is counted per, in the refusal's words.
unit: &'static str,
/// What a document leaving the key out takes.
default: NonZeroU64,
/// The sentence a zero is refused with, given the key's spelling — the one
/// the flag refuses a zero with too.
zero: fn(&str) -> String,
}
impl PositiveSeconds {
fn refused<E: serde::de::Error>(&self, held: &dyn std::fmt::Display) -> E {
E::custom(format!(
"`{}` holds {held}, which is not a positive whole number of {} — give it one, or \
leave the key out to take {} {}",
self.key, self.unit, self.default, self.unit
))
}
}
impl<'de> serde::de::Visitor<'de> for PositiveSeconds {
type Value = Option<NonZeroU64>;
fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(formatter, "a positive whole number of {}", self.unit)
}
fn visit_u64<E: serde::de::Error>(self, seconds: u64) -> std::result::Result<Self::Value, E> {
NonZeroU64::new(seconds)
.map(Some)
.ok_or_else(|| E::custom((self.zero)(&format!("`{}`", self.key))))
}
fn visit_i64<E: serde::de::Error>(self, seconds: i64) -> std::result::Result<Self::Value, E> {
match u64::try_from(seconds) {
Ok(seconds) => self.visit_u64(seconds),
Err(_) => Err(self.refused(&seconds)),
}
}
fn visit_f64<E: serde::de::Error>(self, seconds: f64) -> std::result::Result<Self::Value, E> {
Err(self.refused(&seconds))
}
fn visit_str<E: serde::de::Error>(self, text: &str) -> std::result::Result<Self::Value, E> {
if text.trim().is_empty() {
return Err(E::custom(refused_blank(self.key)));
}
Err(self.refused(&format!("{text:?}")))
}
fn visit_unit<E: serde::de::Error>(self) -> std::result::Result<Self::Value, E> {
Err(E::custom(refused_blank(self.key)))
}
fn visit_none<E: serde::de::Error>(self) -> std::result::Result<Self::Value, E> {
self.visit_unit()
}
fn visit_some<D2: Deserializer<'de>>(
self,
deserializer: D2,
) -> std::result::Result<Self::Value, D2::Error> {
deserializer.deserialize_any(self)
}
}
/// What a key present and holding nothing means.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum BlankValue {
/// Refused by the key's own name: a decision half-written, which everything
/// downstream would read as a launch that named one.
Refused,
/// Read as the document wrote it, whatever that key made of it before —
/// the promise every config already on disk was written against.
Kept,
}
/// A launch config: what a launch declares about its run, as one document.
///
/// The `filters:` block is long enough to be worth keeping in a file beside the
/// plan rather than pasted onto one line of argv, and it is the kind of thing a
/// team writes once and reuses across launches — so `start --launch-config FILE`
/// reads it, and the repeatable flags spell exactly the same block for a launch
/// that would rather say it inline.
///
/// A block rather than a bare `filters:` key at the document root, because what
/// a launch declares is a subject of its own: this is where a second launch-level
/// decision goes, rather than beside the filters that happen to be the first one.
///
/// Versioned and closed. It is **external input** — a file an operator wrote —
/// so an unknown key is refused by name rather than silently dropped, a key a
/// declared version never had is refused by *its* name, and a document declaring
/// a version this build does not read is refused by its number rather than read
/// as though it said something else.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct LaunchConfig {
/// Schema version; [`LAUNCH_CONFIG_SCHEMA_VERSION`] for anything this crate
/// writes.
pub schema_version: u32,
/// What this launch says about its run's events.
///
/// Omitted when empty, so a config that declares nothing about events
/// round-trips as the file wrote it.
#[serde(default, skip_serializing_if = "Filters::is_empty")]
pub filters: Filters,
/// The agent graph this launch drafts change request bodies with, if any.
///
/// The second launch-level decision, and it is one a team writes down beside
/// a plan for the same reason the filters are: which graph authors a change
/// request is a property of how a team works rather than of one launch.
/// `--pr-author-graph` spells the same thing for a launch that would rather
/// say it inline, and overrides this when both are given.
///
/// A key [`LAUNCH_CONFIG_SCHEMA_VERSION`] added, so a document below it may
/// not carry one. Omitted when absent, so a config that names no graph
/// round-trips as the file wrote it — and so what this crate writes for a
/// launch that named none is a document a version-1 reader still accepts.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pr_author_graph: Option<String>,
/// The command this launch checks a live-edited node with, if any.
///
/// The third launch-level decision, and it is written down beside a plan for
/// the reason the first two are: which rules a node has to satisfy before it
/// is dispatched is a property of how a team works rather than of one
/// launch. `--node-validator` spells the same thing for a launch that would
/// rather say it inline and overrides this, as does
/// `ONEPIPELINE_NODE_VALIDATOR` between them.
///
/// A key [`LAUNCH_CONFIG_SCHEMA_VERSION`] added, so a document below it may
/// not carry one. Omitted when absent, so a config that names no validator
/// round-trips as the file wrote it — and so what this crate writes for a
/// launch that named none is a document an earlier reader still accepts.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub node_validator: Option<String>,
/// The command this launch reviews a whole reply envelope with, if any.
///
/// The fourth launch-level decision, and it is written down beside a plan
/// for the reason the first three are: whether a run's edits are reviewed
/// against its goal and its plan before they are committed is a property of
/// how a team works rather than of one launch. `--envelope-reviewer` spells
/// the same thing for a launch that would rather say it inline and overrides
/// this, as does `ONEPIPELINE_ENVELOPE_REVIEWER` between them.
///
/// A key [`LAUNCH_CONFIG_SCHEMA_VERSION`] added, so a document below it may
/// not carry one. Omitted when absent, so a config that names no reviewer
/// round-trips as the file wrote it — and so what this crate writes for a
/// launch that named none is a document an earlier reader still accepts.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub envelope_reviewer: Option<String>,
/// The command whose output fingerprints the bar this launch's envelope
/// reviewer judges against, if any.
///
/// Named, a pass the reviewer gives is recorded under the run's own
/// `validator-passes/`, keyed on the document it judged and on what this
/// command prints when the pass is looked for — so an identical envelope
/// under an unchanged bar is passed without running the reviewer again, and
/// a bar that moved runs it again. `--envelope-reviewer-bar` spells the same
/// thing inline and overrides this, as does `ONEPIPELINE_ENVELOPE_REVIEWER_BAR`
/// between them.
///
/// A key [`LAUNCH_CONFIG_SCHEMA_VERSION`] added, so a document below it may
/// not carry one, refused blank by its own name, and omitted when absent.
// llmlint: ignore[invalid_states_unrepresentable] a command line spelled as the launch config document wrote it, exactly as its sibling `envelope_reviewer` and the hook keys are: this is a public field of the type `docs/contract.md`'s launch config names, a blank one is refused by this key's name where the document is read, and a command newtype would be a public item that contract never promised.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub envelope_reviewer_bar: Option<String>,
/// How long this launch's settlement write-back allows its store's
/// `project copy` per item it writes, in seconds, if the launch says.
///
/// The fifth launch-level decision, written down beside a plan for the
/// reason the first four are: how patient a run is with the board it
/// projects to is a property of the store a team keeps rather than of one
/// launch. The lowest of the three rungs `--writeback-item-budget` heads.
///
/// A key [`LAUNCH_CONFIG_SCHEMA_VERSION`] added, so a document below it may
/// not carry one. Omitted when absent, so a config that names no budget
/// round-trips as the file wrote it.
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "item_budget"
)]
pub writeback_item_budget: Option<NonZeroU64>,
/// The command this launch's run fires once when it ends with every node
/// `done`, if any.
///
/// The sixth launch-level decision, written down beside a plan for the
/// reason the first five are: what happens after a graph completes — a
/// follow-up run launched, a board told — is a property of how a team works
/// rather than of one launch. `--success-hook` spells the same thing inline
/// and overrides this.
///
/// A key [`LAUNCH_CONFIG_SCHEMA_VERSION`] added, so a document below it may
/// not carry one. Blank is kept as written and read at the launch as naming
/// none, which is what the contract says a blank hook is. Omitted when
/// absent, so a config that names no hook round-trips as the file wrote it.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub success_hook: Option<String>,
/// The command this launch's run fires once when it ends any other way, if
/// any.
///
/// Declared, overridden and omitted exactly as
/// [`success_hook`](Self::success_hook) is; `--failure-hook` overrides it.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub failure_hook: Option<String>,
/// How long a run-end hook is awaited, in seconds, if the launch says.
///
/// The lower of the two rungs `--hook-timeout` heads. A key
/// [`LAUNCH_CONFIG_SCHEMA_VERSION`] added, refused blank and refused zero by
/// its own name, and omitted when absent.
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "hook_timeout"
)]
pub hook_timeout: Option<NonZeroU64>,
/// The `onemessagebus` configuration file this launch keeps its run's
/// channel under, if any.
///
/// Read once, at the launch, and retained in the launch record as the
/// document it read: its `authors` block narrows what each author may issue,
/// its `validators` judge what is offered to the channel's queues, and its
/// `codecs.onejudge` block sets what the host bus server waits and reads. A
/// `--bus-config` given inline overrides this.
///
/// A key [`LAUNCH_CONFIG_SCHEMA_VERSION`] added, so a document below it may
/// not carry one, refused blank by its own name, and omitted when absent.
// llmlint: ignore[invalid_states_unrepresentable] a path spelled as the launch config document wrote it, resolved against that document's own directory at the launch and read there, where a path that names no configuration is refused naming this key; a blank one is refused by this key's name where the document is read. It is a public field of the type `docs/contract.md`'s launch config names, beside the other keys written as strings, and a path newtype would be a public item that contract never promised.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bus_config: Option<String>,
/// The command this launch runs immediately before every node-scope
/// dispatch, whose stdout adds environment to that one child launch, if any.
///
/// The seventh launch-level decision, written down beside a plan for the
/// reason the others are: which command produces the environment a host's
/// harness routing indirects through is a property of that host rather than
/// of one launch. `--dispatch-env-hook` spells the same thing inline and
/// overrides this.
///
/// A key [`LAUNCH_CONFIG_SCHEMA_VERSION`] added, so a document below it may
/// not carry one. Blank is kept as written and read at the launch as naming
/// none, as the run-end hooks are. Omitted when absent, so a config that
/// names no hook round-trips as the file wrote it.
// llmlint: ignore[invalid_states_unrepresentable] a command line spelled as the launch config document wrote it, exactly as `success_hook` and `failure_hook` beside it are: this is a public field of the type `docs/contract.md`'s launch config names, a blank one is read at the launch as naming none — which the contract states a blank hook to be — and a command newtype would be a public item that contract never promised.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dispatch_env_hook: Option<String>,
/// How long the dispatch-env hook is awaited, in seconds, if the launch says.
///
/// The lower of the two rungs `--dispatch-env-hook-timeout` heads. A key
/// [`LAUNCH_CONFIG_SCHEMA_VERSION`] added, refused blank and refused zero by
/// its own name, and omitted when absent.
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "dispatch_env_hook_timeout"
)]
pub dispatch_env_hook_timeout: Option<NonZeroU64>,
/// The pool-maintenance schedule this launch's idle driver sweeps on, if
/// any.
///
/// The eighth launch-level decision, written down beside a plan for the
/// reason the others are: how often a host's warm worktrees are kept up is
/// a property of that host rather than of one launch. `--maintenance-config`
/// spells the same thing inline and overrides this.
///
/// A key [`LAUNCH_CONFIG_SCHEMA_VERSION`] added, so a document below it may
/// not carry one. Blank is kept as written and read at the launch as naming
/// none, as the hooks are. Omitted when absent, so a config that names no
/// schedule round-trips as the file wrote it.
// llmlint: ignore[invalid_states_unrepresentable] a path spelled as the launch config document wrote it, exactly as `bus_config` beside it is: resolved against the document's own directory at the launch and read there, where a document this build does not accept is refused naming its key. It is a public field of the type `docs/contract.md`'s launch config names, and a path newtype would be a public item that contract never promised.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub maintenance_config: Option<String>,
}
impl Default for LaunchConfig {
fn default() -> Self {
Self {
schema_version: LAUNCH_CONFIG_SCHEMA_VERSION,
filters: Filters::default(),
pr_author_graph: None,
node_validator: None,
envelope_reviewer: None,
envelope_reviewer_bar: None,
writeback_item_budget: None,
success_hook: None,
failure_hook: None,
hook_timeout: None,
bus_config: None,
dispatch_env_hook: None,
dispatch_env_hook_timeout: None,
maintenance_config: None,
}
}
}
impl LaunchConfig {
/// Read a launch config file: JSON, or the YAML the document is written in,
/// of which JSON is a subset.
///
/// Refused at the same boundary a plan's own project is: this is a file an
/// operator wrote, and the only place it can be refused *before* a run
/// exists is where it is read.
///
/// # Errors
///
/// [`Error::Ledger`] for a file that cannot be read, and [`Error::Invalid`]
/// — naming the path — for a document this schema does not accept, a key its
/// declared version never had, a version this build does not read, or a
/// filter that could not be honoured.
pub fn load(path: &Path) -> Result<Self> {
let text = std::fs::read_to_string(path).map_err(|source| Error::Ledger {
path: path.to_path_buf(),
source,
})?;
let named = |why: String| Error::Invalid(format!("{}: {why}", path.display()));
let config: Self =
serde_norway::from_str(&text).map_err(|failure| named(failure.to_string()))?;
if !LAUNCH_CONFIG_SCHEMA_VERSIONS_READ.contains(&config.schema_version) {
let known = LAUNCH_CONFIG_SCHEMA_VERSIONS_READ
.iter()
.map(u32::to_string)
.collect::<Vec<_>>()
.join(", ");
return Err(named(format!(
"launch config schema_version {}, and this build reads {known} — set \
`schema_version: {LAUNCH_CONFIG_SCHEMA_VERSION}`",
config.schema_version
)));
}
// The field's own name, not the version's number: an operator who wrote a
// drafting graph and had it dropped would find that out from a change
// request nobody drafted a body for, one who wrote a validator would
// find it out from a node nothing checked, and one who wrote a budget
// would find it out from a settlement that never reached the board.
let carried: [(&str, Carried); 12] = [
(
"pr_author_graph",
Carried::text(config.pr_author_graph.as_deref()),
),
(
"node_validator",
Carried::text(config.node_validator.as_deref()),
),
(
"envelope_reviewer",
Carried::text(config.envelope_reviewer.as_deref()),
),
// Never `Blank` here: a blank budget has no number to be read as, so
// `item_budget` refused it by name before this document existed.
(
WRITEBACK_ITEM_BUDGET_KEY,
config
.writeback_item_budget
.map_or(Carried::Absent, |_| Carried::Named),
),
(
"success_hook",
Carried::text(config.success_hook.as_deref()),
),
(
"failure_hook",
Carried::text(config.failure_hook.as_deref()),
),
// Never `Blank`, for the budget's reason: `hook_timeout` refused a
// blank one by name before this document existed.
(
HOOK_TIMEOUT_KEY,
config
.hook_timeout
.map_or(Carried::Absent, |_| Carried::Named),
),
(
"envelope_reviewer_bar",
Carried::text(config.envelope_reviewer_bar.as_deref()),
),
("bus_config", Carried::text(config.bus_config.as_deref())),
(
"dispatch_env_hook",
Carried::text(config.dispatch_env_hook.as_deref()),
),
// Never `Blank`, for the hook timeout's reason.
(
DISPATCH_ENV_HOOK_TIMEOUT_KEY,
config
.dispatch_env_hook_timeout
.map_or(Carried::Absent, |_| Carried::Named),
),
(
crate::maintenance::KEY,
Carried::text(config.maintenance_config.as_deref()),
),
];
for (key, value) in carried {
let Some((arrived, blank)) = KEYS_BY_VERSION
.iter()
.find_map(|(named, at, blank)| (*named == key).then_some((*at, *blank)))
else {
continue;
};
if value != Carried::Absent && config.schema_version < arrived {
return Err(named(format!(
"`{key}` is a schema {arrived} key and this config declares schema_version \
{} — set `schema_version: {LAUNCH_CONFIG_SCHEMA_VERSION}`",
config.schema_version
)));
}
// A key present and blank is a decision half-written: it reads as
// "this launch names one" everywhere downstream and resolves to a
// command nothing can start. Refused here, at the boundary, rather
// than left to fail every edit later — the only thing about a
// command this crate can check is that there is one.
//
// Only for a key that arrives with this version. An older one may
// already carry a blank value in a file somebody wrote, and turning
// that document down would break a launch over a key its author
// never touched — see [`KEYS_BY_VERSION`].
if blank == BlankValue::Refused && value == Carried::Blank {
return Err(named(refused_blank(key)));
}
}
Ok(config)
}
}
/// What one launch says about its run's events.
///
/// Empty is what every launch made before this block existed says, and goes on
/// meaning: nothing is filtered on the way into the store, and the shipped
/// profiles are what a reader reads through.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Filters {
/// Forwarded to every `oneagentgraph` launch this run starts, restricting
/// what that source relays into the merged store.
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "honoured_filter"
)]
pub agentgraph: Option<EventFilter>,
/// Passed to every followed `onevcs` session's stream, restricting what that
/// source relays into the merged store.
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "honoured_filter"
)]
pub vcs: Option<EventFilter>,
/// Named read-time profiles, overriding the shipped ones by name.
#[serde(
default,
skip_serializing_if = "BTreeMap::is_empty",
deserialize_with = "honoured_profiles"
)]
pub profiles: BTreeMap<String, EventFilter>,
}
/// Every field is a filter whose equality is total — a word, a glob, and labels
/// compared as text — so the equivalence the launch record is compared under
/// holds of the block as it did before the filter became the bus's.
impl Eq for Filters {}
/// A source filter the block carries, refused by the grammar's own rules where
/// it is read.
///
/// The bus's filter checks a spec it *parses* and not one a document embeds, so
/// this is where the block's boundary asks the same question: a filter arrives
/// here from a launch config and — every time a later `next` or `monitor` opens a
/// run — from the launch record on disk, which is external input like any other
/// file this process re-reads. A filter checked only where an operator typed it
/// would be a launch record that could be edited into a matcher this build says
/// it will not honour, and then honoured.
fn honoured_filter<'de, D: Deserializer<'de>>(
deserializer: D,
) -> std::result::Result<Option<EventFilter>, D::Error> {
let filter = Option::<EventFilter>::deserialize(deserializer)?;
if let Some(filter) = &filter {
filter.validate().map_err(serde::de::Error::custom)?;
}
Ok(filter)
}
/// Every read-time profile the block carries, each refused as a source filter is.
fn honoured_profiles<'de, D: Deserializer<'de>>(
deserializer: D,
) -> std::result::Result<BTreeMap<String, EventFilter>, D::Error> {
let profiles = BTreeMap::<String, EventFilter>::deserialize(deserializer)?;
for (name, filter) in &profiles {
filter
.validate()
.map_err(|why| serde::de::Error::custom(format!("profile {name:?}: {why}")))?;
}
Ok(profiles)
}
impl Filters {
/// Whether this launch declared nothing at all, which is what a record
/// written before the block existed carries.
#[must_use]
pub fn is_empty(&self) -> bool {
self == &Self::default()
}
/// The profile a reader named, or the reason there is none.
///
/// A launch's own profile of that name wins over the shipped one, so both
/// `planner` and `detailed` are overridable without being special-cased here:
/// the launch's map is consulted first and the shipped defaults are the
/// fallback.
///
/// # Errors
///
/// [`Error::Invalid`] naming the profile asked for and listing the ones this
/// run has, because a planner who mistyped a profile name would otherwise be
/// silently served the default view of a run they meant to look at another
/// way.
pub fn profile(&self, name: &str) -> Result<EventFilter> {
if let Some(filter) = self.profiles.get(name) {
return Ok(filter.clone());
}
if let Some(filter) = shipped_profile(name) {
return Ok(filter);
}
let mut names: Vec<&str> = self.profiles.keys().map(String::as_str).collect();
for shipped in [DEFAULT_PROFILE, DETAILED_PROFILE] {
if !names.contains(&shipped) {
names.push(shipped);
}
}
names.sort_unstable();
Err(Error::Invalid(format!(
"'{name}' is not a filter profile of this run; it has {}",
names.join(", ")
)))
}
}
/// The shipped profile of that name, before any launch override.
///
/// `planner` is every pipeline-level event and nothing else — node dispatch,
/// settlement and failure, decisions, surfaces, edits, attestations, stop and
/// adopt — with the detailed `agentgraph` and `vcs` activity behind them left
/// out, because planner attention is the scarce resource. `detailed` is
/// unfiltered: an observer's whole job is to read the detail.
fn shipped_profile(name: &str) -> Option<EventFilter> {
match name {
DEFAULT_PROFILE => Some(EventFilter {
include: vec![Matcher {
source: Some(Source::Pipeline),
..Matcher::default()
}],
exclude: Vec::new(),
}),
DETAILED_PROFILE => Some(EventFilter::default()),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::Value;
/// The checked-in shape of a launch config, one file per version this build
/// reads.
///
/// Read rather than restated: this is the document an operator writes and a
/// later build parses, and the only thing that stops a key being renamed, an
/// omitted block becoming an explicit empty one, or the version moving
/// without anyone deciding to move it. The earlier ones stay checked in for
/// the half a single golden cannot pin — that a config written before the
/// current version is still a document this build reads.
const GOLDEN: &str = include_str!("../tests/golden/launch-config-v9.json");
/// The same document as each earlier version wrote it: the block it had, and
/// no key that version never had, newest first.
const GOLDEN_EARLIER: [(u32, &str); 8] = [
(8, include_str!("../tests/golden/launch-config-v8.json")),
(7, include_str!("../tests/golden/launch-config-v7.json")),
(6, include_str!("../tests/golden/launch-config-v6.json")),
(5, include_str!("../tests/golden/launch-config-v5.json")),
(4, include_str!("../tests/golden/launch-config-v4.json")),
(3, include_str!("../tests/golden/launch-config-v3.json")),
(2, include_str!("../tests/golden/launch-config-v2.json")),
(1, include_str!("../tests/golden/launch-config-v1.json")),
];
/// The name every golden records its unfiltered override under.
///
/// The goldens are recordings, written when the unfiltered profile shipped
/// under another name, and a recording is never renamed: what they pin is
/// that each still loads as the document it is, override and all. The name
/// is read off the newest golden rather than restated, so the retired word
/// lives in the recordings alone — and that it is *not* the name the
/// profile ships under now is asserted, so a golden regenerated under the
/// current name would be noticed here rather than quietly accepted.
fn recorded_override() -> String {
let value: Value = serde_json::from_str(GOLDEN).expect("the golden is JSON");
let mut names: Vec<String> = value["filters"]["profiles"]
.as_object()
.expect("the golden declares profiles")
.keys()
.filter(|name| name.as_str() != DEFAULT_PROFILE)
.cloned()
.collect();
let [name] = &mut names[..] else {
panic!("the golden declares one override beside `planner`: {names:?}");
};
assert_ne!(
name.as_str(),
DETAILED_PROFILE,
"the golden was regenerated under the current shipped name"
);
std::mem::take(name)
}
/// The filters both goldens carry.
///
/// Both source filters, the shipped `planner` profile and the recorded
/// override, because each is a distinct shape on the wire — an
/// `exclude`-only filter, an `include` of several matchers, an overridden
/// profile, and the empty filter that means "unfiltered" — and a golden
/// carrying one of them would pin a quarter of the document.
fn pinned_filters() -> Filters {
let kind = |glob: &str| Matcher {
kind: Some(glob.to_string()),
..Matcher::default()
};
Filters {
agentgraph: Some(EventFilter {
include: Vec::new(),
exclude: vec![kind("turn-activity")],
}),
vcs: Some(EventFilter {
include: vec![kind("gate-*"), kind("session-closed")],
exclude: Vec::new(),
}),
profiles: [
(
DEFAULT_PROFILE.to_string(),
shipped_profile(DEFAULT_PROFILE).expect("planner ships"),
),
(
recorded_override(),
shipped_profile(DETAILED_PROFILE).expect("detailed ships"),
),
]
.into_iter()
.collect(),
}
}
/// The document [`GOLDEN`] pins, built through the types: the block, and the
/// launch's other decision.
fn golden() -> LaunchConfig {
LaunchConfig {
schema_version: LAUNCH_CONFIG_SCHEMA_VERSION,
filters: pinned_filters(),
pr_author_graph: Some("./graphs/pr-author.yaml".to_string()),
node_validator: Some("./scripts/check-node.sh".to_string()),
envelope_reviewer: Some("./scripts/review-envelope.sh".to_string()),
envelope_reviewer_bar: Some("./scripts/reviewer-bar.sh".to_string()),
writeback_item_budget: NonZeroU64::new(10),
success_hook: Some("./scripts/follow-up.sh".to_string()),
failure_hook: Some("./scripts/report-failure.sh".to_string()),
hook_timeout: NonZeroU64::new(600),
bus_config: Some("./onemessagebus.yaml".to_string()),
dispatch_env_hook: Some("./scripts/dispatch-env.sh".to_string()),
dispatch_env_hook_timeout: NonZeroU64::new(60),
maintenance_config: Some("./maintenance.yml".to_string()),
}
}
#[test]
fn a_launch_config_is_the_shape_its_version_golden_pins() {
let rendered = serde_json::to_string_pretty(&golden()).expect("it serialises");
assert_eq!(
rendered.trim(),
GOLDEN.trim(),
"the launch config changed shape. If that was deliberate, bump \
LAUNCH_CONFIG_SCHEMA_VERSION and add tests/golden/launch-config-v<n>.json \
in the same change"
);
}
#[test]
fn the_schema_version_and_the_golden_name_the_same_number() {
let parsed: LaunchConfig = serde_json::from_str(GOLDEN).expect("the golden parses");
assert_eq!(parsed.schema_version, LAUNCH_CONFIG_SCHEMA_VERSION);
assert_eq!(parsed, golden(), "the golden is not the document it pins");
}
/// Every version before this one is still a document this build reads.
///
/// A promise to every config already written beside a plan: each carries the
/// same block, declares its own number, and says nothing about the keys its
/// version did not have — which is what a launch naming no drafting graph
/// and no validator means. Held against the checked-in files rather than
/// strings built here, because those files are what an operator has on disk.
#[test]
fn every_earlier_version_still_reads_and_says_nothing_about_the_keys_it_never_had() {
for (version, golden) in GOLDEN_EARLIER {
let earlier: LaunchConfig =
serde_json::from_str(golden).expect("the earlier golden parses");
assert_eq!(
earlier,
LaunchConfig {
schema_version: version,
filters: pinned_filters(),
// Version 2 is the one that declared the drafting graph, and
// it names one; version 1 never had the key at all. Version 3
// declared the node validator the same way, and version 4 the
// envelope reviewer.
pr_author_graph: (version >= 2).then(|| "./graphs/pr-author.yaml".to_string()),
node_validator: (version >= 3).then(|| "./scripts/check-node.sh".to_string()),
envelope_reviewer: (version >= 4)
.then(|| "./scripts/review-envelope.sh".to_string()),
// Version 5 declared the write-back's budget, version 6 the
// run-end hooks, version 7 the bus and a reviewer's bar,
// version 8 the dispatch-env hook, and none of them the
// maintenance schedule.
writeback_item_budget: NonZeroU64::new(10).filter(|_| version >= 5),
success_hook: (version >= 6).then(|| "./scripts/follow-up.sh".to_string()),
failure_hook: (version >= 6).then(|| "./scripts/report-failure.sh".to_string()),
hook_timeout: NonZeroU64::new(600).filter(|_| version >= 6),
envelope_reviewer_bar: (version >= 7)
.then(|| "./scripts/reviewer-bar.sh".to_string()),
bus_config: (version >= 7).then(|| "./onemessagebus.yaml".to_string()),
dispatch_env_hook: (version >= 8)
.then(|| "./scripts/dispatch-env.sh".to_string()),
dispatch_env_hook_timeout: NonZeroU64::new(60).filter(|_| version >= 8),
maintenance_config: None,
}
);
assert!(
LAUNCH_CONFIG_SCHEMA_VERSIONS_READ.contains(&earlier.schema_version),
"the version the earlier golden declares is not one this build reads"
);
// And it is an *earlier* document, not this one wearing an older
// number: what this build writes carries the current version.
assert_ne!(earlier.schema_version, LAUNCH_CONFIG_SCHEMA_VERSION);
}
}
/// The launch's other two decisions round-trip when they are there and are
/// written as no key at all when they are not.
///
/// The second half is what keeps each bump additive: a launch that named
/// neither is written as a document carrying nothing about drafting or
/// validating, which is what an earlier reader accepts and what an earlier
/// file already says.
#[test]
fn the_launch_level_keys_round_trip_when_named_and_are_omitted_when_they_are_not() {
let named = LaunchConfig {
pr_author_graph: Some("./graphs/pr-author.yaml".to_string()),
node_validator: Some("./scripts/check-node.sh".to_string()),
envelope_reviewer: Some("./scripts/review-envelope.sh".to_string()),
writeback_item_budget: NonZeroU64::new(15),
success_hook: Some("./scripts/follow-up.sh".to_string()),
failure_hook: Some("./scripts/report-failure.sh".to_string()),
hook_timeout: NonZeroU64::new(30),
dispatch_env_hook: Some("./scripts/dispatch-env.sh".to_string()),
dispatch_env_hook_timeout: NonZeroU64::new(20),
..LaunchConfig::default()
};
let rendered = serde_json::to_string(&named).expect("it serialises");
assert_eq!(
rendered,
format!(
r#"{{"schema_version":{LAUNCH_CONFIG_SCHEMA_VERSION},"pr_author_graph":"./graphs/pr-author.yaml","node_validator":"./scripts/check-node.sh","envelope_reviewer":"./scripts/review-envelope.sh","writeback_item_budget":15,"success_hook":"./scripts/follow-up.sh","failure_hook":"./scripts/report-failure.sh","hook_timeout":30,"dispatch_env_hook":"./scripts/dispatch-env.sh","dispatch_env_hook_timeout":20}}"#
)
);
assert_eq!(
serde_json::from_str::<LaunchConfig>(&rendered).expect("it re-parses"),
named
);
let unnamed = LaunchConfig::default();
let rendered = serde_json::to_string(&unnamed).expect("it serialises");
for key in [
"pr_author_graph",
"node_validator",
"envelope_reviewer",
WRITEBACK_ITEM_BUDGET_KEY,
"success_hook",
"failure_hook",
HOOK_TIMEOUT_KEY,
"dispatch_env_hook",
DISPATCH_ENV_HOOK_TIMEOUT_KEY,
] {
assert!(
!rendered.contains(key),
"a launch that named no {key} was written one: {rendered}"
);
}
assert_eq!(
serde_json::from_str::<LaunchConfig>(&rendered).expect("it re-parses"),
unnamed
);
}
/// A config that declares no events round-trips as the file wrote it.
///
/// The backward-compatible half, checked at the wire rather than through the
/// types: `Filters::default()` and an explicit `filters: {}` are the same
/// value in Rust whatever the serializer does, but writing the empty block
/// out would have every consumer branching on a key that is always present
/// and usually meaningless — and would stop a document written before the
/// block existed from being what this build writes back.
#[test]
fn a_launch_config_declaring_no_events_omits_the_block_and_round_trips() {
let bare = LaunchConfig::default();
let rendered = serde_json::to_string(&bare).expect("it serialises");
assert_eq!(
rendered,
format!(r#"{{"schema_version":{LAUNCH_CONFIG_SCHEMA_VERSION}}}"#)
);
assert_eq!(
serde_json::from_str::<LaunchConfig>(&rendered).expect("it re-parses"),
bare
);
// And the version alone is a whole document, at every version this build
// reads: a config that says nothing else is what a launch naming no
// filters already means.
for version in LAUNCH_CONFIG_SCHEMA_VERSIONS_READ {
let minimal: LaunchConfig =
serde_norway::from_str(&format!("schema_version: {version}\n"))
.expect("a bare config parses");
assert_eq!(minimal.schema_version, version);
assert!(minimal.filters.is_empty());
assert_eq!(minimal.pr_author_graph, None);
assert_eq!(minimal.node_validator, None);
assert_eq!(minimal.envelope_reviewer, None);
assert_eq!(minimal.writeback_item_budget, None);
assert_eq!(minimal.success_hook, None);
assert_eq!(minimal.failure_hook, None);
assert_eq!(minimal.hook_timeout, None);
assert_eq!(minimal.dispatch_env_hook, None);
assert_eq!(minimal.dispatch_env_hook_timeout, None);
}
}
/// Every filter shape survives the wire, and an empty list is never written.
#[test]
fn a_launch_config_round_trips_without_losing_or_inventing_a_field() {
let full = golden();
let text = serde_norway::to_string(&full).expect("it serialises as YAML too");
assert_eq!(
serde_norway::from_str::<LaunchConfig>(&text).expect("it re-parses"),
full
);
// The unfiltered profile is `{}` on the wire — both lists empty, and
// neither written — so a reader can tell "admits everything" from a
// profile that was never declared.
let value: Value = serde_json::from_str(GOLDEN).expect("the golden is JSON");
assert_eq!(
value["filters"]["profiles"][recorded_override()],
serde_json::json!({})
);
assert!(
value["filters"]["agentgraph"].get("include").is_none(),
"an empty include was written out: {value}"
);
}
/// Every golden's recorded override — under the name the unfiltered profile
/// shipped as when it was written — still loads as the profile it declared,
/// and is what a reader naming it is served; and a launch declaring
/// `detailed` overrides the profile that ships under that name now.
///
/// Two halves of one promise: a launch configuration already on disk keeps
/// meaning what it meant, and the shipped name is overridable like the one
/// before it was.
#[test]
fn a_recorded_override_still_loads_by_its_name_and_detailed_overrides_the_shipped_profile() {
let retired = recorded_override();
for (version, golden) in GOLDEN_EARLIER
.iter()
.copied()
.chain(std::iter::once((LAUNCH_CONFIG_SCHEMA_VERSION, GOLDEN)))
{
let config: LaunchConfig = serde_json::from_str(golden)
.unwrap_or_else(|why| panic!("the schema-{version} golden no longer loads: {why}"));
assert_eq!(
config
.filters
.profile(&retired)
.unwrap_or_else(|why| panic!(
"the schema-{version} golden's `{retired}` override is not a profile the \
run has: {why}"
)),
EventFilter::default(),
"the schema-{version} golden's `{retired}` override is not the profile it declared"
);
// And it is the launch's own, not a shipped one wearing that name.
assert!(
Filters::default().profile(&retired).is_err(),
"`{retired}` reads as a shipped profile, so the golden's override proved nothing"
);
}
let mine = EventFilter {
include: vec![Matcher {
kind: Some("node-*".to_string()),
..Matcher::default()
}],
exclude: Vec::new(),
};
let launch = Filters {
profiles: [(DETAILED_PROFILE.to_string(), mine.clone())]
.into_iter()
.collect(),
..Filters::default()
};
assert_eq!(
launch.profile(DETAILED_PROFILE).expect("the launch's own"),
mine,
"the shipped `detailed` profile was read instead of the launch's override"
);
assert_eq!(
Filters::default()
.profile(DETAILED_PROFILE)
.expect("detailed ships"),
EventFilter::default(),
"a launch declaring nothing is not served the shipped `detailed` profile"
);
}
/// A config already on disk carrying a blank `pr_author_graph` reads exactly
/// as it always did.
///
/// The regression this exists for: the blank-value refusal that arrived with
/// `node_validator` was written for every key at once, and applied to
/// `pr_author_graph` it turns down a document an operator wrote against a
/// build that accepted it — a launch broken over a key its author never
/// touched. Whatever a blank drafting graph meant at version 2 it goes on
/// meaning: the value is read as written, `Some("")` and not `None`, and the
/// document loads.
///
/// Held at both versions that have the key, and with the surrounding block
/// intact, because what has to keep working is the file as it is on disk
/// rather than the key on its own.
#[test]
fn a_config_carrying_a_blank_drafting_graph_still_loads_as_it_always_did() {
let root = std::env::temp_dir().join(format!(
"onepipeline-config-blank-drafting-{}",
std::process::id()
));
std::fs::create_dir_all(&root).expect("a scratch directory");
for version in [2, LAUNCH_CONFIG_SCHEMA_VERSION] {
for written in ["\"\"", "\" \""] {
let path = root.join(format!("v{version}-{}.yaml", written.len()));
std::fs::write(
&path,
format!(
"schema_version: {version}\n\
pr_author_graph: {written}\n\
filters:\n\
\x20 vcs:\n\
\x20 include:\n\
\x20 - kind: session-closed\n"
),
)
.expect("the config is written");
let read = LaunchConfig::load(&path).unwrap_or_else(|refusal| {
panic!(
"a schema-{version} config carrying a blank `pr_author_graph` no longer \
loads, which breaks every one already on disk: {refusal}"
)
});
assert_eq!(
read.pr_author_graph.as_deref(),
// As written, whitespace and all: this build does not decide
// for an operator what their blank value meant.
Some(written.trim_matches('"')),
"a blank drafting graph was read as something other than what the file said"
);
assert_eq!(read.schema_version, version);
// The rest of the document is untouched by any of this.
assert!(read.filters.vcs.is_some(), "the block was dropped");
assert_eq!(read.node_validator, None);
assert_eq!(read.envelope_reviewer, None);
assert_eq!(read.writeback_item_budget, None);
}
}
std::fs::remove_dir_all(&root).ok();
}
/// A run-end hook present and blank loads, as a launch saying it has none.
///
/// Deliberately not the validator's and the reviewer's refusal: the contract
/// states a blank hook as naming none, so the document reads as written and
/// the launch decides what it means.
#[test]
fn a_blank_run_end_hook_is_kept_as_written_rather_than_refused() {
let root = std::env::temp_dir().join(format!(
"onepipeline-config-blank-hook-{}",
std::process::id()
));
std::fs::create_dir_all(&root).expect("a scratch directory");
let path = root.join("blank.yaml");
std::fs::write(
&path,
format!(
"schema_version: {LAUNCH_CONFIG_SCHEMA_VERSION}\nsuccess_hook: \"\"\n\
failure_hook: \" \"\n"
),
)
.expect("the config is written");
let read = LaunchConfig::load(&path).expect("a blank hook loads");
assert_eq!(read.success_hook.as_deref(), Some(""));
assert_eq!(read.failure_hook.as_deref(), Some(" "));
assert_eq!(read.hook_timeout, None);
std::fs::remove_dir_all(&root).ok();
}
/// The version is refused by its number, an unknown key by its name, and a
/// key an earlier version never had by *that* key's name.
#[test]
fn a_launch_config_this_build_cannot_read_is_refused_by_name() {
let root = std::env::temp_dir().join(format!("onepipeline-config-{}", std::process::id()));
std::fs::create_dir_all(&root).expect("a scratch directory");
let written = |name: &str, body: &str| {
let path = root.join(name);
std::fs::write(&path, body).expect("the config is written");
path
};
// A number this build has never written, told the versions it reads.
let unread = LAUNCH_CONFIG_SCHEMA_VERSION + 1;
let later = LaunchConfig::load(&written(
"later.yaml",
&format!("schema_version: {unread}\n"),
))
.expect_err("a version this build does not read is refused");
let said = later.to_string();
assert!(said.contains(&format!("schema_version {unread}")), "{said}");
for version in LAUNCH_CONFIG_SCHEMA_VERSIONS_READ {
assert!(
said.contains(&version.to_string()),
"the refusal does not name version {version}, which this build reads: {said}"
);
}
// A key the version this document declares never had: refused by that
// key's name, because the key is what its author has to act on and the
// alternative is a drafting graph nobody drafts with, or a validator
// that checks nothing.
//
// Each key is refused by **its own** arrival version rather than by the
// schema's current number: a version-2 config naming the drafting graph
// version 2 introduced is a document this build reads, and a rule
// written the other way would have turned it down the day an unrelated
// key moved the schema on.
for (key, arrived, value) in [
("pr_author_graph", 2, "./graphs/pr-author.yaml"),
("node_validator", 3, "./scripts/check-node.sh"),
("envelope_reviewer", 4, "./scripts/review-envelope.sh"),
(WRITEBACK_ITEM_BUDGET_KEY, 5, "12"),
("success_hook", 6, "./scripts/follow-up.sh"),
("failure_hook", 6, "./scripts/report-failure.sh"),
(HOOK_TIMEOUT_KEY, 6, "45"),
("dispatch_env_hook", 8, "./scripts/dispatch-env.sh"),
(DISPATCH_ENV_HOOK_TIMEOUT_KEY, 8, "20"),
] {
let early = LaunchConfig::load(&written(
&format!("early-{key}.yaml"),
&format!("schema_version: {}\n{key}: {value}\n", arrived - 1),
))
.expect_err("a key a declared version never had is refused");
let said = early.to_string();
assert!(said.contains(&format!("`{key}`")), "{said}");
assert!(said.contains(&format!("schema {arrived} key")), "{said}");
// And the same document at the version that has it is read.
let read = LaunchConfig::load(&written(
&format!("arrived-{key}.yaml"),
&format!("schema_version: {arrived}\n{key}: {value}\n"),
))
.expect("the version that declares the key reads it");
let budget = read
.writeback_item_budget
.map(|seconds| seconds.to_string());
let timeout = read.hook_timeout.map(|seconds| seconds.to_string());
let dispatch_timeout = read
.dispatch_env_hook_timeout
.map(|seconds| seconds.to_string());
let named = match key {
"pr_author_graph" => read.pr_author_graph.as_deref(),
"node_validator" => read.node_validator.as_deref(),
"envelope_reviewer" => read.envelope_reviewer.as_deref(),
"success_hook" => read.success_hook.as_deref(),
"failure_hook" => read.failure_hook.as_deref(),
"dispatch_env_hook" => read.dispatch_env_hook.as_deref(),
HOOK_TIMEOUT_KEY => timeout.as_deref(),
DISPATCH_ENV_HOOK_TIMEOUT_KEY => dispatch_timeout.as_deref(),
_ => budget.as_deref(),
};
assert_eq!(named, Some(value));
}
// A hook key present and blank: a decision half-written rather than a
// launch that declared nothing. Both of them, because each is refused
// from the version it arrived at rather than only the newest one.
for key in ["node_validator", "envelope_reviewer"] {
let blank = LaunchConfig::load(&written(
&format!("blank-{key}.yaml"),
&format!("schema_version: {LAUNCH_CONFIG_SCHEMA_VERSION}\n{key}: \" \"\n"),
))
.expect_err("a hook that names nothing is refused");
let said = blank.to_string();
assert!(
said.contains(&format!("`{key}`")) && said.contains("names nothing"),
"{said}"
);
}
// The budget present and blank is the same half-written decision, in
// each spelling a YAML author reaches for: the bare key, and an empty or
// whitespace string. A number has no blank to be read as, so serde's own
// reading would take the first as the key omitted and the other two as
// a type it did not expect — and each has to be refused by the key's
// own name instead, with the sentence the hook keys are.
for (spelled, written_as) in [
("bare", format!("{WRITEBACK_ITEM_BUDGET_KEY}:")),
("empty", format!("{WRITEBACK_ITEM_BUDGET_KEY}: \"\"")),
("spaces", format!("{WRITEBACK_ITEM_BUDGET_KEY}: \" \"")),
] {
let blank = LaunchConfig::load(&written(
&format!("blank-budget-{spelled}.yaml"),
&format!("schema_version: {LAUNCH_CONFIG_SCHEMA_VERSION}\n{written_as}\n"),
))
.expect_err("a budget that names nothing is refused");
let said = blank.to_string();
assert!(
said.contains(&format!("`{WRITEBACK_ITEM_BUDGET_KEY}`")),
"a {spelled} budget was not refused by the key's name: {said}"
);
}
// And a budget of zero is refused by name too, rather than read as a
// launch that named none: zero is no budget at all, so it never falls
// through to the shipped default.
let zero = LaunchConfig::load(&written(
"zero-budget.yaml",
&format!(
"schema_version: {LAUNCH_CONFIG_SCHEMA_VERSION}\n{WRITEBACK_ITEM_BUDGET_KEY}: 0\n"
),
))
.expect_err("a budget of zero is refused");
let said = zero.to_string();
assert!(
said.contains(&format!("`{WRITEBACK_ITEM_BUDGET_KEY}`")) && said.contains("zero"),
"{said}"
);
// A negative or fractional number is not a whole number of seconds, and
// the refusal still names the key.
for (spelled, value) in [("negative", "-5"), ("fractional", "2.5")] {
let refused = LaunchConfig::load(&written(
&format!("{spelled}-budget.yaml"),
&format!(
"schema_version: {LAUNCH_CONFIG_SCHEMA_VERSION}\n{WRITEBACK_ITEM_BUDGET_KEY}: {value}\n"
),
))
.expect_err("a budget that is not a whole number of seconds is refused");
let said = refused.to_string();
assert!(
said.contains(WRITEBACK_ITEM_BUDGET_KEY),
"a {spelled} budget was not refused by the key's name: {said}"
);
}
// A hook timeout — either hook's — is refused on the budget's terms, by
// its own name: blank in each spelling, zero, and anything that is not a
// whole number.
for key in [HOOK_TIMEOUT_KEY, DISPATCH_ENV_HOOK_TIMEOUT_KEY] {
for (spelled, written_as) in [
("bare", format!("{key}:")),
("empty", format!("{key}: \"\"")),
("zero", format!("{key}: 0")),
("negative", format!("{key}: -5")),
("fractional", format!("{key}: 2.5")),
] {
let refused = LaunchConfig::load(&written(
&format!("{key}-{spelled}.yaml"),
&format!("schema_version: {LAUNCH_CONFIG_SCHEMA_VERSION}\n{written_as}\n"),
))
.expect_err("a timeout that is not a positive whole number is refused");
let said = refused.to_string();
assert!(
said.contains(&format!("`{key}`")),
"a {spelled} timeout was not refused by the key's name: {said}"
);
if spelled == "zero" {
assert!(said.contains("zero"), "{said}");
}
}
}
let stray = LaunchConfig::load(&written(
"stray.yaml",
"schema_version: 1\nfilterz:\n vcs: {}\n",
))
.expect_err("a key this schema does not declare is refused");
assert!(stray.to_string().contains("filterz"), "{stray}");
// The filter grammar's own refusals reach here too: the config is one
// more boundary the same spec crosses.
let unusable = LaunchConfig::load(&written(
"unusable.yaml",
"schema_version: 1\nfilters:\n vcs:\n include:\n - role: agent\n",
))
.expect_err("a matcher field the grammar does not have is refused");
assert!(unusable.to_string().contains("role"), "{unusable}");
let missing = LaunchConfig::load(&root.join("nothing-here.yaml"))
.expect_err("a file that is not there is refused");
assert!(missing.to_string().contains("nothing-here"), "{missing}");
let _ = std::fs::remove_dir_all(&root);
}
}