use std::collections::BTreeSet;
use std::sync::{Arc, Mutex, OnceLock, PoisonError};
use onemessagebus::{
Allowlist, AskOptions, Bus, BusError, Config, ConsumerName, Correlation, Fingerprint, Layouts,
Lifetime, LocalTransport, Message, OpWord, Pending, QueueError, QueueName, QueueSpec, RawQueue,
Read, Registry, SchemaId, Transport, TransportConfig, TransportKinds,
};
use onemessagebus_agent::channel::{
PlannerChannel, COMMANDS, COMMAND_OUTCOMES, PLANNER_CHANNEL, REPLIES, SURFACES,
};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use crate::note::{Addressee, Criterion, NoteText};
use crate::plan::Node;
pub const REPLY_ENVELOPE_VERSION: u32 = onemessagebus_agent::channel::REPLY_ENVELOPE_VERSION;
pub const REPLY_ENVELOPE_VERSIONS_READ: &[u32] =
onemessagebus_agent::registry::REPLY_ENVELOPE_READS;
fn registry() -> &'static std::sync::Arc<Registry> {
static REGISTRY: std::sync::OnceLock<std::sync::Arc<Registry>> = std::sync::OnceLock::new();
REGISTRY.get_or_init(|| std::sync::Arc::new(onemessagebus_agent::registry()))
}
fn read_at_a_version_this_build_reads<'de, D>(deserializer: D) -> Result<Option<u32>, D::Error>
where
D: serde::Deserializer<'de>,
{
let declared = Option::<u32>::deserialize(deserializer)?;
Ok(declared.map(|version| {
match registry().read_at(onemessagebus_agent::REPLY_ENVELOPE_FAMILY, version) {
Read::At(read) => read,
Read::Unknown(_) => version,
}
}))
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
#[serde(try_from = "String", into = "String")]
pub struct Author(String);
impl Author {
pub fn planner() -> Self {
Self("planner".into())
}
pub fn as_str(&self) -> &str {
&self.0
}
pub(crate) fn is_planner(&self) -> bool {
self.as_str() == "planner"
}
pub(crate) fn word(&self) -> onemessagebus::Author {
onemessagebus::Author::from(self.as_str())
}
}
impl Default for Author {
fn default() -> Self {
Self::planner()
}
}
impl From<&str> for Author {
fn from(word: &str) -> Self {
Self(word.into())
}
}
impl TryFrom<String> for Author {
type Error = String;
fn try_from(word: String) -> Result<Self, Self::Error> {
valid_word(&word)
.then(|| Self(word.clone()))
.ok_or_else(|| {
format!("'{word}' is not an author: authors match ^[a-z][a-z0-9-]{{0,63}}$")
})
}
}
impl From<Author> for String {
fn from(author: Author) -> Self {
author.0
}
}
fn valid_word(word: &str) -> bool {
(1..=64).contains(&word.len())
&& word.bytes().next().is_some_and(|b| b.is_ascii_lowercase())
&& word
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
}
fn profile_allowlist() -> &'static Allowlist<OpWord> {
static ALLOWLIST: std::sync::OnceLock<Allowlist<OpWord>> = std::sync::OnceLock::new();
ALLOWLIST.get_or_init(|| onemessagebus_agent::channel::allowlist().words())
}
pub fn allows_completion(author: Author, completion: Option<bool>) -> crate::Result<()> {
completion_allowed_by(profile_allowlist(), author, completion)
}
pub(crate) fn completion_allowed_by(
allowlist: &Allowlist<OpWord>,
author: Author,
completion: Option<bool>,
) -> crate::Result<()> {
onemessagebus_agent::channel::allows_completion(allowlist, &author.word(), completion)
.map_err(crate::Error::Refused)
}
pub fn allows(author: Author, command: &Command) -> crate::Result<()> {
allowed_by(profile_allowlist(), author, command)
}
pub(crate) fn allowed_by(
allowlist: &Allowlist<OpWord>,
author: Author,
command: &Command,
) -> crate::Result<()> {
onemessagebus_agent::channel::allows(allowlist, &author.word(), op_of(command))
.map_err(crate::Error::Refused)
}
pub fn op_of(command: &Command) -> &'static str {
match command {
Command::Add { .. } => "add",
Command::Drop { .. } => "drop",
Command::Reparent { .. } => "reparent",
Command::Retry { .. } => "retry",
Command::Cancel { .. } => "cancel",
Command::Requeue { .. } => "requeue",
Command::Attest { .. } => "attest",
Command::Complete { .. } => "complete",
Command::Amend { .. } => "amend",
Command::Note { .. } => "note",
Command::Finding { .. } => "finding",
Command::Settle { .. } => "settle",
}
}
pub fn target_of(command: &Command) -> Option<String> {
match command {
Command::Add { node } => Some(node.id.clone()),
Command::Drop { id, .. }
| Command::Reparent { id, .. }
| Command::Retry { id, .. }
| Command::Cancel { id, .. }
| Command::Requeue { id, .. }
| Command::Note { id, .. }
| Command::Settle { id, .. }
| Command::Amend { id, .. } => Some(id.clone()),
Command::Attest { reference } => Some(reference.clone()),
Command::Finding { id, .. } => id.clone(),
Command::Complete { .. } => None,
}
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct Reply {
#[serde(
default,
deserialize_with = "read_at_a_version_this_build_reads",
skip_serializing_if = "Option::is_none"
)]
pub version: Option<u32>,
#[serde(default, skip_serializing_if = "Author::is_planner")]
pub author: Author,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub completion: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub commands: Vec<Command>,
}
impl Message for Reply {
const SCHEMA: SchemaId = SchemaId::literal("agent", "reply-envelope", REPLY_ENVELOPE_VERSION);
}
impl Reply {
pub(crate) fn carries_verdict(&self) -> bool {
self.completion.is_some() || self.message.is_some() || self.reason.is_some()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "lowercase")]
pub enum Dependents {
Drop,
Detach,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "op", rename_all = "lowercase", deny_unknown_fields)]
pub enum Command {
Add {
#[schemars(with = "Map<String, Value>")]
node: Node,
},
Drop {
id: String,
dependents: Dependents,
},
Reparent {
id: String,
deps: Vec<String>,
},
Retry {
id: String,
#[schemars(with = "Map<String, Value>")]
node: Node,
},
Cancel {
id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
reason: Option<String>,
},
Requeue {
id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
amend: Option<Map<String, Value>>,
},
Attest {
#[serde(rename = "ref")]
reference: String,
},
Complete {
reason: String,
},
Amend {
id: String,
text: String,
},
Note {
id: String,
addressee: Addressee,
text: NoteText,
#[serde(default, skip_serializing_if = "Option::is_none")]
criterion: Option<Criterion>,
#[serde(default, skip_serializing_if = "Deliver::is_default")]
deliver: Deliver,
#[serde(default = "persists", skip_serializing_if = "is_true")]
persist: bool,
},
Finding {
message: String,
#[serde(default, skip_serializing_if = "is_false")]
blocking: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
id: Option<String>,
},
Settle {
id: String,
outcome: SettleOutcome,
evidence: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
landing: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
release: Option<StatedRelease>,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct StatedRelease {
#[schemars(with = "String")]
pub target: onevcs::releases::TargetName,
pub version: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "lowercase")]
pub enum SettleOutcome {
Done,
Failed,
}
impl SettleOutcome {
pub fn as_str(self) -> &'static str {
match self {
Self::Done => "done",
Self::Failed => "failed",
}
}
}
fn is_false(value: &bool) -> bool {
!*value
}
fn is_true(value: &bool) -> bool {
*value
}
fn persists() -> bool {
true
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "lowercase")]
pub enum Deliver {
#[default]
Live,
Next,
}
impl Deliver {
fn is_default(&self) -> bool {
matches!(self, Self::Live)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
#[serde(try_from = "String", into = "String")]
pub struct SurfaceKind(String);
impl SurfaceKind {
pub const CHECK_IN: &'static str = "check-in";
pub const FINDING: &'static str = "finding";
pub const EDIT_APPLIED: &'static str = "edit-applied";
pub fn check_in() -> Self {
Self(Self::CHECK_IN.into())
}
pub fn finding() -> Self {
Self(Self::FINDING.into())
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl std::str::FromStr for SurfaceKind {
type Err = String;
fn from_str(word: &str) -> Result<Self, Self::Err> {
valid_word(word).then(|| Self(word.into())).ok_or_else(|| {
format!("'{word}' is not a surface kind: kinds match ^[a-z][a-z0-9-]{{0,63}}$")
})
}
}
impl TryFrom<String> for SurfaceKind {
type Error = String;
fn try_from(word: String) -> Result<Self, Self::Error> {
word.parse()
}
}
impl From<SurfaceKind> for String {
fn from(kind: SurfaceKind) -> Self {
kind.0
}
}
pub const REPLY_TIMEOUT_ENV: &str = "ONEPIPELINE_REPLY_TIMEOUT_SECONDS";
pub const DEFAULT_REPLY_TIMEOUT_SECONDS: u64 = 30;
pub const ASKER_ENV: &str = "ONEPIPELINE_CHANNEL_ASKER";
pub(crate) use onemessagebus::Asker;
fn recorded_asker<'de, D: serde::Deserializer<'de>>(
deserializer: D,
) -> Result<Option<Asker>, D::Error> {
Ok(Option::<String>::deserialize(deserializer)?
.and_then(|name| Asker::new(&name, "the recorded asker").ok()))
}
fn recorded_correlation<'de, D: serde::Deserializer<'de>>(
deserializer: D,
) -> Result<Option<Correlation>, D::Error> {
Ok(Option::<String>::deserialize(deserializer)?.and_then(|text| text.parse().ok()))
}
pub(crate) use onemessagebus_agent::channel::source;
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize, JsonSchema)]
pub(crate) struct Surface {
pub id: u64,
pub kind: String,
pub message: String,
pub source: String,
pub blocking: bool,
pub queued_at: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workstream: Option<String>,
#[serde(default, skip_serializing_if = "is_false")]
pub abandoned: bool,
#[serde(
default,
deserialize_with = "recorded_asker",
skip_serializing_if = "Option::is_none"
)]
pub asker: Option<Asker>,
#[serde(
default,
deserialize_with = "recorded_correlation",
skip_serializing_if = "Option::is_none"
)]
pub correlation: Option<Correlation>,
}
impl Message for Surface {
const SCHEMA: SchemaId = onemessagebus_agent::channel::SURFACE_SCHEMA;
}
#[derive(Clone)]
pub(crate) struct ChannelState {
paths: crate::ledger::RunPaths,
config: Option<Arc<Config>>,
transport: Arc<OnceLock<std::result::Result<Arc<dyn Transport>, String>>>,
surfaces: Arc<OnceLock<std::result::Result<onemessagebus::Queue<Surface>, String>>>,
bus: Arc<OnceLock<std::result::Result<Bus, String>>>,
judging: Arc<OnceLock<std::result::Result<Bus, String>>>,
seen: Arc<Mutex<Option<(Fingerprint, Queue)>>>,
}
impl std::fmt::Debug for ChannelState {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ChannelState")
.field("run", &self.paths.run)
.finish_non_exhaustive()
}
}
#[derive(Debug, Clone, Default, PartialEq)]
pub(crate) struct Queue {
pub waiting: Vec<Surface>,
pub pending: Option<Surface>,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub(crate) struct QueuedReply {
pub id: u64,
pub reply: Reply,
pub at: u64,
#[serde(
default,
deserialize_with = "recorded_correlation",
skip_serializing_if = "Option::is_none"
)]
pub correlation: Option<Correlation>,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub(crate) struct QueuedCommands {
pub id: u64,
#[serde(default)]
pub author: Author,
pub commands: Vec<Command>,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub(crate) struct CommandOutcome {
pub id: u64,
pub applied: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub results: Vec<CommandResult>,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub(crate) struct CommandResult {
pub index: usize,
pub op: String,
pub outcome: CommandVerdict,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "lowercase")]
pub(crate) enum CommandVerdict {
Applied,
Validated,
Delivered,
Refused,
}
fn planner_channel() -> Layouts {
Layouts::new().with(Arc::new(PlannerChannel))
}
pub(crate) fn launch_bus_config(path: &std::path::Path) -> crate::Result<Config> {
let named =
|why: String| crate::Error::Invalid(format!("--bus-config {}: {why}", path.display()));
let mut config = Config::load(path).map_err(|failure| named(failure.to_string()))?;
if config.transport.kind.as_str() != onemessagebus::LOCAL {
return Err(named(format!(
"transport.kind is `{}`, and a run's channel is kept on the `{local}` transport over \
the run's own channel directory — name `kind: {local}`",
config.transport.kind,
local = onemessagebus::LOCAL,
)));
}
if let Some(dir) = &config.transport.dir {
return Err(named(format!(
"transport.dir is `{}`, and where a run's channel is kept is its run root's to \
decide rather than the configuration's — leave `transport.dir` out",
dir.display()
)));
}
if let Some((key, value)) = config.transport.options.iter().next() {
let value = value
.as_str()
.map_or_else(|| value.to_string(), str::to_owned);
return Err(named(format!(
"transport.{key} is `{value}`, which the {} transport does not take",
onemessagebus::LOCAL
)));
}
if let Some(profile) = config
.profile
.as_deref()
.filter(|profile| *profile != PLANNER_CHANNEL)
{
return Err(named(format!(
"profile is `{profile}`, and a run's channel is the `{PLANNER_CHANNEL}` layout — name \
it, or leave `profile` out"
)));
}
if let Some(queue) = config.queues.keys().next() {
return Err(named(format!(
"queues.{queue} is declared, and a run's queues are the `{PLANNER_CHANNEL}` layout's \
— leave `queues` out"
)));
}
config.profile = Some(PLANNER_CHANNEL.to_owned());
let mut probe = config.clone();
probe.transport = TransportConfig {
kind: onemessagebus::MEMORY
.parse()
.map_err(|failure| named(format!("{failure}")))?,
dir: None,
options: Map::new(),
};
probe
.resolve(&planner_channel(), &TransportKinds::builtin())
.map_err(|failure| named(failure.to_string()))?;
Ok(config)
}
fn queue_name(text: &str) -> QueueName {
QueueName::try_from(text)
.unwrap_or_else(|_| unreachable!("{text} is a queue the planner-channel layout declares"))
}
fn declared(text: &str) -> QueueSpec {
onemessagebus_agent::channel::queues()
.into_iter()
.find(|spec| spec.name.as_str() == text)
.unwrap_or_else(|| unreachable!("the planner-channel layout declares {text}"))
}
fn queue_failure(failure: QueueError) -> crate::Error {
match failure {
QueueError::NoIdLeft { last, .. } => crate::Error::Refused(format!(
"surface: the channel has no id left to allocate; the last one, {last}, has already \
been queued"
)),
QueueError::Refused { reason, .. } | QueueError::Unjudged { reason, .. } => {
crate::Error::Refused(reason)
}
other => crate::Error::Refused(other.to_string()),
}
}
fn bus_failure(failure: BusError) -> crate::Error {
match failure {
BusError::Refused { why, .. } => crate::Error::Refused(why),
BusError::Queue(failure) => queue_failure(failure),
other => crate::Error::Refused(other.to_string()),
}
}
#[allow(
dead_code,
reason = "shared bus primitives remain part of the library channel API after the CLI server was retired"
)]
fn read_surfaces(records: Vec<Value>) -> crate::Result<Vec<Surface>> {
records
.into_iter()
.map(|record| {
serde_json::from_value(record)
.map_err(|failure| crate::Error::Invalid(format!("surface: {failure}")))
})
.collect()
}
const ECHOED_TOKEN_PREFIX: &str = "ask-manager-token:";
fn echoed_token(message: &str) -> Option<String> {
message.lines().find_map(|line| {
line.split_once(ECHOED_TOKEN_PREFIX)
.map(|(_, rest)| format!("{ECHOED_TOKEN_PREFIX}{}", rest.trim()))
})
}
#[allow(
dead_code,
reason = "shared bus primitives remain part of the library channel API after the CLI server was retired"
)]
impl ChannelState {
pub fn new(paths: &crate::ledger::RunPaths) -> Self {
Self {
paths: paths.clone(),
config: None,
transport: Arc::default(),
surfaces: Arc::default(),
bus: Arc::default(),
judging: Arc::default(),
seen: Arc::default(),
}
}
pub(crate) fn of_run(
paths: &crate::ledger::RunPaths,
launch: &crate::ledger::LaunchRecord,
) -> Self {
Self {
config: launch.bus_config.clone().map(Arc::new),
..Self::new(paths)
}
}
fn transport(&self) -> crate::Result<Arc<dyn Transport>> {
self.transport
.get_or_init(|| {
LocalTransport::open(self.paths.channel_dir())
.map(|local| Arc::new(local) as Arc<dyn Transport>)
.map_err(|failure| failure.to_string())
})
.clone()
.map_err(crate::Error::Refused)
}
fn surfaces(&self) -> crate::Result<onemessagebus::Queue<Surface>> {
if let Some(opened) = self.surfaces.get() {
return opened.clone().map_err(crate::Error::Refused);
}
let transport = self.transport()?;
self.surfaces
.get_or_init(|| {
onemessagebus::Queue::open(transport, declared(SURFACES))
.map_err(|failure| failure.to_string())
})
.clone()
.map_err(crate::Error::Refused)
}
fn plain(&self, name: &str) -> crate::Result<RawQueue> {
Ok(RawQueue::open(
self.transport()?,
declared(name),
Arc::clone(registry()),
))
}
fn bus(&self) -> crate::Result<Bus> {
self.bus
.get_or_init(|| self.resolved(false))
.clone()
.map_err(crate::Error::Refused)
}
fn judging_bus(&self) -> crate::Result<Bus> {
if self
.config
.as_ref()
.is_none_or(|config| config.validators.is_empty())
{
return self.bus();
}
self.judging
.get_or_init(|| self.resolved(true))
.clone()
.map_err(crate::Error::Refused)
}
fn resolved(&self, judging: bool) -> std::result::Result<Bus, String> {
let mut config = match &self.config {
Some(config) => (**config).clone(),
None => Config::local(self.paths.channel_dir(), Some(PLANNER_CHANNEL)),
}
.with_transport_dir(self.paths.channel_dir());
config.profile = Some(PLANNER_CHANNEL.to_owned());
if !judging {
config.validators.clear();
}
config
.resolve(&planner_channel(), &TransportKinds::builtin())
.map_err(|failure| failure.to_string())
}
fn validates(&self, queue: &str) -> bool {
self.config.as_ref().is_some_and(|config| {
config
.validators
.iter()
.any(|validator| validator.on.as_str() == queue)
})
}
pub(crate) fn allows(&self, author: Author, command: &Command) -> crate::Result<()> {
match &self.config {
None => allowed_by(profile_allowlist(), author, command),
Some(_) => allowed_by(self.bus()?.allowlist(), author, command),
}
}
pub(crate) fn declares(&self, author: &Author) -> crate::Result<()> {
let configured;
let allowlist = match &self.config {
None => profile_allowlist(),
Some(_) => {
configured = self.bus()?;
configured.allowlist()
}
};
if allowlist.declares(&author.word()) {
return Ok(());
}
Err(crate::Error::Refused(format!(
"the envelope's author `{}` is not declared; the declared authors are: {}",
author.as_str(),
allowlist
.authors()
.iter()
.map(onemessagebus::Author::as_str)
.collect::<Vec<_>>()
.join(", ")
)))
}
pub(crate) fn allows_completion(
&self,
author: Author,
completion: Option<bool>,
) -> crate::Result<()> {
match &self.config {
None => completion_allowed_by(profile_allowlist(), author, completion),
Some(_) => completion_allowed_by(self.bus()?.allowlist(), author, completion),
}
}
pub(crate) fn fingerprint(&self) -> Vec<Fingerprint> {
let Ok(transport) = self.transport() else {
return Vec::new();
};
[SURFACES, COMMANDS]
.into_iter()
.filter_map(|name| transport.fingerprint(&queue_name(name)).ok())
.collect()
}
pub fn queue(&self) -> Queue {
if !self.paths.channel_dir().is_dir() {
return Queue::default();
}
let Ok(surfaces) = self.surfaces() else {
return Queue::default();
};
let mark = surfaces.raw().fingerprint().ok();
if let Some(mark) = &mark {
let seen = self.seen.lock().unwrap_or_else(PoisonError::into_inner);
if let Some((seen, queue)) = seen.as_ref() {
if seen == mark {
return queue.clone();
}
}
}
let queue = Queue {
waiting: surfaces.waiting().unwrap_or_default(),
pending: surfaces
.raw()
.held()
.ok()
.flatten()
.and_then(|held| serde_json::from_value(held.record).ok()),
};
if let Some(mark) = mark {
*self.seen.lock().unwrap_or_else(PoisonError::into_inner) = Some((mark, queue.clone()));
}
queue
}
pub fn push(&self, surface: Surface) -> crate::Result<Surface> {
if self.validates(SURFACES) {
let surfaces = queue_name(SURFACES);
let offered = serde_json::to_value(&surface)
.map_err(|failure| crate::Error::Invalid(format!("surface: {failure}")))?;
QueueError::of_verdict(
&surfaces,
self.judging_bus()?
.validate(&surfaces, offered)
.map_err(bus_failure)?,
)
.map_err(queue_failure)?;
}
Ok(self
.surfaces()?
.push(&surface)
.map_err(queue_failure)?
.record)
}
pub fn claim(&self) -> crate::Result<Option<Surface>> {
Ok(self
.surfaces()?
.claim(&ConsumerName::default_consumer())
.map_err(queue_failure)?
.map(|claimed| claimed.record))
}
pub fn abandon(&self, raised: &[u64]) -> crate::Result<Vec<Surface>> {
read_surfaces(
self.surfaces()?
.raw()
.abandon(raised)
.map_err(queue_failure)?,
)
}
pub fn attend(&self, asker: &Asker) -> crate::Result<Vec<Surface>> {
read_surfaces(
self.surfaces()?
.raw()
.attend(asker)
.map_err(queue_failure)?,
)
}
pub fn pending(&self) -> Option<Surface> {
self.held().filter(|surface| !surface.abandoned)
}
pub fn held(&self) -> Option<Surface> {
self.queue().pending
}
pub fn answer(&self, reply: &Reply) -> crate::Result<u64> {
self.answer_bound(reply, None)
}
pub(crate) fn answer_bound(
&self,
reply: &Reply,
named: Option<&Correlation>,
) -> crate::Result<u64> {
let bus = self.bus()?;
let framed = serde_json::json!({"id": 0, "reply": reply, "at": crate::sys::now_millis()});
if let Some(correlation) = named {
return Self::bound_through(&bus, correlation, framed);
}
let id = match self.binding_for(&bus, reply)? {
Some(correlation) => Self::bound_through(&bus, &correlation, framed)?,
None => {
let replies = queue_name(REPLIES);
QueueError::of_verdict(
&replies,
bus.validate(&replies, framed.clone())
.map_err(bus_failure)?,
)
.map_err(queue_failure)?;
self.surfaces()?
.raw()
.answer_pending()
.map_err(queue_failure)?;
self.plain(REPLIES)?
.push(framed)
.map_err(queue_failure)?
.id
.unwrap_or_default()
}
};
self.surfaces()?
.raw()
.answer_pending()
.map_err(queue_failure)?;
Ok(id)
}
fn bound_through(bus: &Bus, correlation: &Correlation, framed: Value) -> crate::Result<u64> {
let bound = bus
.reply(&queue_name(SURFACES), Some(correlation), framed)
.map_err(bus_failure)?;
Ok(bound
.sent
.iter()
.find(|(queue, _)| queue.as_str() == REPLIES)
.and_then(|(_, pushed)| pushed.id)
.unwrap_or_default())
}
fn binding_for(&self, bus: &Bus, reply: &Reply) -> crate::Result<Option<Correlation>> {
let outstanding = self.outstanding()?;
if outstanding.is_empty() {
return Ok(None);
}
if let Some(message) = &reply.message {
if let Some(asked) = outstanding.iter().find(|asked| {
echoed_token(&asked.message).is_some_and(|token| message.contains(&token))
}) {
return Ok(asked.correlation.clone());
}
}
if let Some(held) = self.held().and_then(|held| held.correlation) {
if outstanding
.iter()
.any(|asked| asked.correlation.as_ref() == Some(&held))
{
return Ok(Some(held));
}
}
let surfaces = queue_name(SURFACES);
for correlation in outstanding
.iter()
.filter_map(|asked| asked.correlation.as_ref())
{
let listener = bus
.listen::<Value>(&surfaces, correlation, &Lifetime::Session)
.map_err(bus_failure)?;
if !listener.is_abandoned().map_err(queue_failure)? {
return Ok(Some(correlation.clone()));
}
}
Ok(None)
}
fn outstanding(&self) -> crate::Result<Vec<Surface>> {
let answered: BTreeSet<Correlation> = self
.replies()
.into_iter()
.filter_map(|queued| queued.correlation)
.collect();
let mut asked = BTreeSet::new();
Ok(self
.surfaces()?
.raw()
.log(None)
.map_err(queue_failure)?
.into_iter()
.filter(|(line, _)| line.get("event").and_then(Value::as_str) == Some("queued"))
.filter_map(|(line, _)| serde_json::from_value::<Surface>(line).ok())
.filter(|surface| {
surface.correlation.as_ref().is_some_and(|correlation| {
!answered.contains(correlation) && asked.insert(correlation.clone())
})
})
.collect())
}
pub fn answer_if_verdict(&self, reply: &Reply) -> crate::Result<()> {
self.answer_if_verdict_bound(reply, None)
}
pub(crate) fn answer_if_verdict_bound(
&self,
reply: &Reply,
named: Option<&Correlation>,
) -> crate::Result<()> {
if reply.carries_verdict() {
self.answer_bound(reply, named)?;
}
Ok(())
}
pub fn replies(&self) -> Vec<QueuedReply> {
let Ok(replies) = self.plain(REPLIES) else {
return Vec::new();
};
replies
.log(None)
.unwrap_or_default()
.into_iter()
.filter_map(|(record, _)| serde_json::from_value(record).ok())
.collect()
}
pub fn claim_reply(&self) -> crate::Result<Option<QueuedReply>> {
let replies = self.plain(REPLIES)?;
while let Some(claimed) = replies
.claim(&ConsumerName::default_consumer())
.map_err(queue_failure)?
{
if let Ok(queued) = serde_json::from_value::<QueuedReply>(claimed.record) {
return Ok(Some(queued));
}
}
Ok(None)
}
pub(crate) fn delivered(&self, reply: &QueuedReply) -> crate::Result<()> {
let behind = self
.plain(REPLIES)?
.waiting()
.map_err(queue_failure)?
.iter()
.any(|record| record.get("id").and_then(Value::as_u64) == Some(reply.id));
if behind {
while let Some(claimed) = self.claim_reply()? {
if claimed.id >= reply.id {
break;
}
}
}
Ok(())
}
pub(crate) fn ask(&self, question: Surface) -> crate::Result<Pending<Value>> {
let options = AskOptions {
blocking: question.blocking,
asker: question.asker.clone(),
about: None,
};
self.judging_bus()?
.ask::<Surface, Value>(&queue_name(SURFACES), question, options)
.map_err(bus_failure)
}
pub(crate) fn listen(
&self,
correlation: &Correlation,
asker: Option<&Asker>,
) -> crate::Result<Pending<Value>> {
let lifetime = asker.map_or(Lifetime::Session, |asker| Lifetime::Durable(asker.clone()));
self.bus()?
.listen::<Value>(&queue_name(SURFACES), correlation, &lifetime)
.map_err(bus_failure)
}
pub(crate) fn owed(&self, asker: Option<&Asker>) -> crate::Result<Option<QueuedReply>> {
let raised: BTreeSet<Correlation> = match asker {
Some(asker) => self
.surfaces()?
.raw()
.log(None)
.map_err(queue_failure)?
.into_iter()
.filter(|(line, _)| line.get("event").and_then(Value::as_str) == Some("queued"))
.filter_map(|(line, _)| serde_json::from_value::<Surface>(line).ok())
.filter(|surface| surface.asker.as_ref() == Some(asker))
.filter_map(|surface| surface.correlation)
.collect(),
None => BTreeSet::new(),
};
Ok(self
.plain(REPLIES)?
.waiting()
.map_err(queue_failure)?
.into_iter()
.filter_map(|record| serde_json::from_value::<QueuedReply>(record).ok())
.find(|reply| {
reply
.correlation
.as_ref()
.is_none_or(|correlation| raised.contains(correlation))
}))
}
pub(crate) fn replies_mark(&self) -> crate::Result<Fingerprint> {
self.transport()?
.fingerprint(&queue_name(REPLIES))
.map_err(|failure| crate::Error::Refused(failure.to_string()))
}
pub(crate) fn wait_for_replies(
&self,
since: &Fingerprint,
timeout: std::time::Duration,
) -> crate::Result<()> {
self.transport()?
.wait_for_change(&queue_name(REPLIES), since, timeout)
.map(|_| ())
.map_err(|failure| crate::Error::Refused(failure.to_string()))
}
pub(crate) fn judge_reply<V: onemessagebus::Validator<Value> + 'static>(
&self,
reply: &Reply,
review: Option<V>,
) -> crate::Result<()> {
if review.is_none() && !self.validates(REPLIES) && !self.validates(COMMANDS) {
return Ok(());
}
let replies = queue_name(REPLIES);
let offered = serde_json::to_value(reply)
.map_err(|failure| crate::Error::Invalid(format!("reply: {failure}")))?;
let mut bus = self.judging_bus()?;
if let Some(review) = review {
bus = bus
.with_validator::<Value>(&replies, review)
.map_err(bus_failure)?;
}
QueueError::of_verdict(
&replies,
bus.validate(&replies, offered).map_err(bus_failure)?,
)
.map_err(queue_failure)
}
pub fn submit(&self, author: Author, commands: &[Command]) -> crate::Result<u64> {
let offered = serde_json::json!({"id": 0, "author": author, "commands": commands});
let sent = self
.bus()?
.send(&queue_name(COMMANDS), offered)
.map_err(bus_failure)?;
Ok(sent
.first()
.and_then(|(_, pushed)| pushed.id)
.unwrap_or_default())
}
pub(crate) fn claimable_commands(&self) -> Vec<QueuedCommands> {
let Ok(commands) = self.plain(COMMANDS) else {
return Vec::new();
};
commands
.waiting()
.unwrap_or_default()
.into_iter()
.filter_map(|record| serde_json::from_value(record).ok())
.collect()
}
pub fn claim_commands(&self) -> crate::Result<Vec<QueuedCommands>> {
let commands = self.plain(COMMANDS)?;
let mut claimed = Vec::new();
while let Some(envelope) = commands
.claim(&ConsumerName::default_consumer())
.map_err(queue_failure)?
{
if let Ok(envelope) = serde_json::from_value(envelope.record) {
claimed.push(envelope);
}
}
Ok(claimed)
}
pub fn answer_commands(&self, outcome: &CommandOutcome) -> crate::Result<()> {
let record = serde_json::to_value(outcome)
.map_err(|failure| crate::Error::Invalid(format!("outcome: {failure}")))?;
self.plain(COMMAND_OUTCOMES)?
.push(record)
.map_err(queue_failure)?;
Ok(())
}
pub fn outcome_of(&self, id: u64) -> Option<CommandOutcome> {
self.plain(COMMAND_OUTCOMES)
.ok()?
.log(None)
.ok()?
.into_iter()
.filter_map(|(record, _)| serde_json::from_value::<CommandOutcome>(record).ok())
.find(|outcome| outcome.id == id)
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
const ENVELOPE_GOLDEN: &str = include_str!("../tests/golden/reply-envelope-v3.json");
const ENVELOPE_GOLDEN_BEFORE: &str = include_str!("../tests/golden/reply-envelope-v2.json");
fn envelope_golden() -> Reply {
let settled = |id: &str, outcome: SettleOutcome, evidence: &str, landing: Option<&str>| {
Command::Settle {
id: id.to_owned(),
outcome,
evidence: evidence.to_owned(),
landing: landing.map(str::to_owned),
release: (id == "release").then(|| StatedRelease {
target: "crate".parse().expect("a target name"),
version: "0.2.31".to_owned(),
}),
}
};
Reply {
version: Some(REPLY_ENVELOPE_VERSION),
author: Author::planner(),
commands: vec![
settled(
"publish",
SettleOutcome::Done,
"the change merged while the dispatch was dying; the run recorded the \
death and never the merge",
Some("https://github.com/owner/engine/pull/12"),
),
settled(
"release",
SettleOutcome::Done,
"the operator read the merge on the base branch",
Some("3f9a1c2e5b7d9081f2a3b4c5d6e7f8091a2b3c4d"),
),
settled(
"announce",
SettleOutcome::Failed,
"the wait it was on can never clear, and nothing published",
None,
),
],
..Reply::default()
}
}
#[test]
fn the_reply_envelope_is_the_shape_the_golden_pins() {
let rendered =
serde_json::to_string_pretty(&envelope_golden()).expect("the envelope serialises");
assert_eq!(
rendered.trim(),
ENVELOPE_GOLDEN.trim(),
"the reply envelope changed shape. Bump REPLY_ENVELOPE_VERSION, add the golden \
for the new version beside this one, keep this one as the version the build goes \
on reading, and say so in entry 57"
);
}
#[test]
fn both_goldens_validate_under_this_crates_schema_and_the_profiles_documents() {
let mut own = onemessagebus::Registry::new();
own.register::<Reply>()
.expect("this crate's envelope schema registers");
let profile = onemessagebus_agent::registry();
let at = |version: u32| SchemaId::literal("agent", "reply-envelope", version);
for (golden, version) in [(ENVELOPE_GOLDEN, 3), (ENVELOPE_GOLDEN_BEFORE, 2)] {
let document: Value = serde_json::from_str(golden).expect("the golden is JSON");
own.check(&Reply::SCHEMA, &document)
.unwrap_or_else(|failure| {
panic!(
"the version {version} golden is refused by this crate's schema: {failure}"
)
});
profile.check(&at(version), &document).unwrap_or_else(|failure| {
panic!("the version {version} golden is refused by the profile's document: {failure}")
});
}
let mut stray: Value = serde_json::from_str(ENVELOPE_GOLDEN).expect("the golden is JSON");
stray["stray"] = json!(true);
assert!(own.check(&Reply::SCHEMA, &stray).is_err());
assert!(profile.check(&at(3), &stray).is_err());
}
#[test]
fn an_envelope_at_the_version_before_this_one_is_still_read() {
let before: Reply =
serde_json::from_str(ENVELOPE_GOLDEN_BEFORE).expect("the older envelope still reads");
let declared: Value =
serde_json::from_str(ENVELOPE_GOLDEN_BEFORE).expect("the golden is JSON");
assert_eq!(
declared["version"],
json!(2),
"the golden for the version before this one is not at that version"
);
assert_eq!(
before.version,
Some(REPLY_ENVELOPE_VERSION),
"an envelope at a version this build reads was not read at the version it reads"
);
assert!(
REPLY_ENVELOPE_VERSIONS_READ.contains(&2),
"the version that golden was written against is no longer read"
);
assert_eq!(
before.commands.len(),
2,
"the older envelope's commands did not survive: {before:?}"
);
for command in &before.commands {
let Command::Settle {
landing, release, ..
} = command
else {
panic!("the older envelope carries something other than a settle: {command:?}");
};
assert_eq!(
landing.as_deref(),
None,
"a settle written before the landing existed came back carrying one"
);
assert_eq!(
release, &None,
"a settle written before it gained a release"
);
}
let ancient: Reply = serde_json::from_value(json!({
"version": 1,
"commands": [{"op": "cancel", "id": "build"}],
}))
.expect("an unreadable version is not a parse failure");
assert_eq!(
ancient.version,
Some(1),
"a version this build does not read was carried forward as one it does"
);
}
#[test]
fn a_settled_landing_round_trips_at_both_spellings_and_is_omitted_where_there_is_none() {
let envelope = envelope_golden();
let read: Reply =
serde_json::from_str(ENVELOPE_GOLDEN).expect("the golden reads back into the types");
assert_eq!(read, envelope, "the golden is not the envelope it pins");
let again: Reply =
serde_json::from_str(&serde_json::to_string(&envelope).expect("it serialises"))
.expect("it reads back");
assert_eq!(again, envelope, "the envelope does not round-trip");
let document: Value =
serde_json::from_str(&serde_json::to_string(&envelope).expect("it serialises"))
.expect("it is JSON");
assert_eq!(
document["commands"][0]["landing"],
json!("https://github.com/owner/engine/pull/12"),
"the change-request spelling of a landing did not survive the wire"
);
assert_eq!(
document["commands"][1]["landing"],
json!("3f9a1c2e5b7d9081f2a3b4c5d6e7f8091a2b3c4d"),
"the commit spelling of a landing did not survive the wire"
);
assert!(
document["commands"][2].get("landing").is_none(),
"a settle that named no landing carries a landing key anyway: {}",
document["commands"][2]
);
let bare = json!({
"version": REPLY_ENVELOPE_VERSION,
"commands": [{
"op": "settle", "id": "announce", "outcome": "failed",
"evidence": "the wait it was on can never clear, and nothing published",
}],
});
let before: Reply = serde_json::from_value(bare.clone()).expect("it parses");
assert_eq!(
serde_json::to_value(&before).expect("it serialises"),
bare,
"an envelope written before the landing existed did not round-trip unchanged"
);
}
#[test]
fn a_token_is_read_off_the_first_line_that_carries_it() {
assert_eq!(
echoed_token("Blocked on this.\nThe token to echo:\nask-manager-token:0a1b2c \n"),
Some("ask-manager-token:0a1b2c".to_owned())
);
assert_eq!(
echoed_token("ask-manager-token:first\nask-manager-token:second"),
Some("ask-manager-token:first".to_owned())
);
assert_eq!(echoed_token("nothing to echo here"), None);
}
#[test]
fn the_token_prefix_is_the_one_the_contract_and_the_readme_state() {
for document in ["docs/contract.md", "README.md"] {
let text = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join(document),
)
.expect("the document ships");
assert!(
text.contains(&format!("`{ECHOED_TOKEN_PREFIX}`")),
"{document} does not state the prefix `{ECHOED_TOKEN_PREFIX}` a verdict echoes"
);
}
}
struct Scratch {
root: std::path::PathBuf,
channel: ChannelState,
}
impl Scratch {
fn new(name: &str) -> Self {
let root = std::env::temp_dir()
.join(format!("onepipeline-channel-{name}-{}", crate::sys::pid()));
let _ = std::fs::remove_dir_all(&root);
let paths = crate::ledger::RunPaths::under(&root, "bound");
paths.create().expect("the run directory");
Self {
channel: ChannelState::new(&paths),
root,
}
}
fn asked(&self, message: &str, blocking: bool) -> Correlation {
self.channel
.ask(Surface {
id: 0,
kind: "planner-question".to_owned(),
message: message.to_owned(),
source: source::PROPOSAL.to_owned(),
blocking,
queued_at: 1,
workstream: None,
abandoned: false,
asker: None,
correlation: None,
})
.expect("the question is asked")
.correlation()
.clone()
}
fn verdict(message: &str) -> Reply {
Reply {
completion: Some(false),
message: Some(message.to_owned()),
..Reply::default()
}
}
}
impl Drop for Scratch {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.root);
}
}
#[test]
fn a_verdict_binds_to_the_question_whose_token_it_echoes() {
let scratch = Scratch::new("token");
let first = scratch.asked("first\nask-manager-token:aaaa", true);
let second = scratch.asked("second\nask-manager-token:bbbb", true);
scratch.channel.claim().expect("the first is claimed");
let id = scratch
.channel
.answer(&Scratch::verdict("main, ask-manager-token:bbbb"))
.expect("the verdict is answered");
let replies = scratch.channel.replies();
assert_eq!(replies.len(), 1);
assert_eq!(replies[0].id, id);
assert_eq!(replies[0].correlation.as_ref(), Some(&second));
scratch
.channel
.answer(&Scratch::verdict("carry on"))
.expect("the second verdict is answered");
assert_eq!(
scratch.channel.replies()[1].correlation.as_ref(),
Some(&first)
);
assert_eq!(scratch.channel.held(), None, "the slot was not released");
}
#[test]
fn a_verdict_bound_to_nothing_is_queued_as_it_was_and_a_named_stranger_is_refused() {
let scratch = Scratch::new("unbound");
scratch
.channel
.answer(&Scratch::verdict("nothing asked"))
.expect("the verdict is queued");
let queued = scratch.channel.replies();
assert_eq!(queued.len(), 1);
assert_eq!(queued[0].correlation, None);
let line = std::fs::read_to_string(scratch.root.join("bound/channel/replies.jsonl"))
.expect("the reply log");
assert!(!line.contains("correlation"), "{line}");
let stranger: Correlation = "c-not-asked".parse().expect("a correlation");
let refused = scratch
.channel
.answer_bound(&Scratch::verdict("to nobody"), Some(&stranger))
.expect_err("a correlation nothing holds binds nothing");
assert!(refused.to_string().contains("c-not-asked"), "{refused}");
assert_eq!(
scratch.channel.replies().len(),
1,
"a refused reply was appended"
);
}
#[test]
fn a_run_with_no_channel_directory_reads_as_an_empty_queue_and_gains_none() {
let scratch = Scratch::new("absent");
let channel = scratch.root.join("bound/channel");
std::fs::remove_dir_all(&channel).expect("the channel directory is removed");
assert_eq!(scratch.channel.queue(), Queue::default());
assert!(!channel.exists(), "a read made the channel directory");
}
}