use std::io::Read;
use std::path::PathBuf;
use std::time::Duration;
use onemessagebus::sdk_schema::Asked as Wire;
use onemessagebus::{Address, Answer, Asker, Correlation, Pending};
use serde_json::{json, Value};
use crate::channel::layout::SURFACES;
use crate::channel::{source, ChannelState, Surface};
use crate::cli::AskArgs;
use crate::error::{Error, Result, EXIT_QUEUED, EXIT_SUCCESS};
use crate::ledger::{self, LaunchRecord, RecordedBusConfig, RunPaths};
pub(crate) const QUESTION_KIND: &str = "planner-question";
pub(crate) const RUN_ID_ENV: &str = crate::agentgraph::RUN_ID_ENV;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Form {
Arguments,
File,
Stdin,
}
impl Form {
fn as_str(self) -> &'static str {
match self {
Self::Arguments => "the argument words",
Self::File => "the file named with --file",
Self::Stdin => "standard input",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Text(String);
impl Text {
fn into_inner(self) -> String {
self.0
}
}
pub(crate) fn question(args: &AskArgs) -> Result<Text> {
let (form, text) = match (&args.file, args.text.is_empty()) {
(Some(path), _) => (Form::File, read_file(path)?),
(None, false) => (Form::Arguments, args.text.join(" ")),
(None, true) => {
let mut text = String::new();
std::io::stdin()
.read_to_string(&mut text)
.map_err(|error| {
Error::Invalid(format!(
"the question on standard input could not be read: {error}"
))
})?;
(Form::Stdin, text)
}
};
if text.contains('\0') {
return Err(Error::Invalid(format!(
"the question from {} carries a NUL byte, which no frame can carry; remove it and \
ask again — nothing was asked",
form.as_str()
)));
}
if text.trim().is_empty() {
return Err(Error::Invalid(format!(
"the question from {} is blank; state the decision fork and what each branch would \
change, so the manager can answer it in one reply — nothing was asked",
form.as_str()
)));
}
Ok(Text(text))
}
fn read_file(path: &PathBuf) -> Result<String> {
std::fs::read_to_string(path).map_err(|error| {
Error::Invalid(format!(
"the question file {} could not be read: {error}; check the path, or pipe the \
question in on standard input instead",
path.display()
))
})
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct RunId(String);
impl RunId {
pub(crate) fn as_str(&self) -> &str {
&self.0
}
}
pub(crate) fn run_id() -> Result<RunId> {
std::env::var(RUN_ID_ENV)
.ok()
.filter(|run| !run.trim().is_empty())
.map(RunId)
.ok_or_else(|| {
Error::Invalid(format!(
"{RUN_ID_ENV} is not set, so there is no run whose channel to ask on; run this \
from inside a dispatch, which exports it, or export the run id yourself"
))
})
}
pub(crate) fn asker() -> Result<Option<Asker>> {
match std::env::var_os(crate::channel::ASKER_ENV) {
None => Ok(None),
Some(word) => Asker::named(&word, crate::channel::ASKER_ENV)
.map(Some)
.map_err(|refusal| {
Error::Invalid(format!(
"{refusal}; unset it, or set it to the word a later session of this dispatch \
asks as"
))
}),
}
}
pub(crate) fn about(args: &AskArgs) -> Result<Option<Address>> {
args.about
.as_deref()
.map(|text| {
text.parse::<Address>()
.map_err(|refusal| Error::Invalid(format!("--about: {refusal}")))
})
.transpose()
}
#[derive(Debug)]
pub(crate) struct Request {
pub message: Text,
pub asker: Option<Asker>,
pub about: Option<Address>,
pub timeout: Option<std::num::NonZeroU64>,
}
pub(crate) enum Question {
Pending {
pending: Box<Pending<Value>>,
window: Duration,
},
Refused(String),
}
impl Question {
pub(crate) fn raise(paths: &RunPaths, request: Request) -> Result<Self> {
let launch: LaunchRecord = ledger::read_json(&paths.launch())?;
let window = request
.timeout
.map(|seconds| Duration::from_secs(seconds.get()))
.or_else(|| reply_window(launch.bus_config.as_ref().map(RecordedBusConfig::config)))
.unwrap_or(onemessagebus::DEFAULT_REPLY_WINDOW);
let channel = ChannelState::of_run(paths, &launch);
let question = Surface {
id: 0,
kind: QUESTION_KIND.to_owned(),
message: request.message.into_inner(),
source: source::PROPOSAL.to_owned(),
blocking: true,
queued_at: crate::sys::now_millis(),
abandoned: false,
asker: request.asker,
workstream: None,
correlation: None,
};
Ok(match channel.ask(question, request.about) {
Ok(pending) => Self::Pending {
pending: Box::new(pending),
window,
},
Err(error) => Self::Refused(error.to_string()),
})
}
pub(crate) fn window(&self) -> Duration {
match self {
Self::Pending { window, .. } => *window,
Self::Refused(_) => Duration::ZERO,
}
}
pub(crate) fn correlation(&self) -> Option<&Correlation> {
match self {
Self::Pending { pending, .. } => Some(pending.correlation()),
Self::Refused(_) => None,
}
}
pub(crate) fn answer(self) -> Asked {
match self {
Self::Refused(reason) => Asked {
wire: Wire::Refused {
correlation: None,
reason,
},
unmarked: None,
},
Self::Pending { pending, window } => {
let answer = pending.wait(window);
let unmarked = matches!(answer, Answer::Timeout)
.then(|| pending.abandon().err().map(|error| error.to_string()))
.flatten();
let correlation = pending.correlation().clone();
let wire = match answer {
Answer::Reply(reply) => Wire::Reply { correlation, reply },
Answer::Timeout => Wire::Timeout { correlation },
Answer::Abandoned => Wire::Abandoned { correlation },
Answer::Refused(refusal) => Wire::Refused {
correlation: Some(correlation),
reason: refusal.reason,
},
};
Asked { wire, unmarked }
}
}
}
}
fn reply_window(config: Option<&onemessagebus::Config>) -> Option<Duration> {
config?
.codecs
.values()
.filter(|codec| {
codec
.queue
.as_ref()
.is_some_and(|queue| queue.as_str() == SURFACES)
})
.filter_map(|codec| codec.reply_window_seconds)
.map(|seconds| Duration::from_secs(seconds.get()))
.max()
}
#[derive(Debug)]
pub(crate) struct Asked {
wire: Wire,
unmarked: Option<String>,
}
impl Asked {
pub(crate) const fn exit_code(&self) -> i32 {
match self.wire {
Wire::Reply { .. } => EXIT_SUCCESS,
Wire::Timeout { .. } | Wire::Abandoned { .. } | Wire::Refused { .. } => EXIT_QUEUED,
}
}
pub(crate) fn render(&self) -> String {
serde_json::to_string(&self.wire).unwrap_or_else(|error| {
json!({"answer": "refused", "reason": format!("the answer could not be rendered: {error}")})
.to_string()
})
}
pub(crate) fn advice(&self, run: &str, window: Duration) -> Option<String> {
match &self.wire {
Wire::Reply { .. } => None,
Wire::Timeout { correlation } => Some(format!(
"no reply echoing {correlation} arrived within {} seconds; the question stands \
on the channel, {}, and a manager may still answer it with `onepipeline reply \
{run} --correlation {correlation}`",
window.as_secs(),
self.unmarked.as_ref().map_or_else(
|| "marked abandoned".to_owned(),
|why| format!(
"but could not be marked abandoned ({why}), so a later listener will \
not take it back"
)
)
)),
Wire::Abandoned { correlation } => Some(format!(
"the question {correlation} was abandoned and nobody re-attended it; ask again"
)),
Wire::Refused { reason, .. } => Some(format!("the question was refused: {reason}")),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn each_answer_renders_as_the_bus_prints_it() {
let correlation: Correlation = "c-1".parse().expect("a correlation");
let answers = [
Wire::Reply {
correlation: correlation.clone(),
reply: json!({"id": 1, "reply": {"completion": false}}),
},
Wire::Timeout {
correlation: correlation.clone(),
},
Wire::Abandoned {
correlation: correlation.clone(),
},
Wire::Refused {
correlation: None,
reason: "no".into(),
},
];
for wire in answers {
let asked = Asked {
wire: wire.clone(),
unmarked: None,
};
let line = asked.render();
assert!(!line.contains('\n'), "{line}");
let read: Wire = serde_json::from_str(&line).expect("the bus reads its own answer");
assert_eq!(read, wire, "{line}");
let reply = matches!(wire, Wire::Reply { .. });
assert_eq!(
asked.exit_code(),
if reply { EXIT_SUCCESS } else { EXIT_QUEUED }
);
assert_eq!(asked.advice("r", Duration::from_secs(7)).is_none(), reply);
}
let marked = Asked {
wire: Wire::Timeout {
correlation: correlation.clone(),
},
unmarked: None,
}
.advice("r", Duration::from_secs(7))
.expect("advice");
assert!(
marked.contains("within 7 seconds") && marked.contains("marked abandoned"),
"{marked}"
);
}
#[test]
fn the_reply_window_is_the_longest_a_surfaces_codec_names() {
let config = |codecs: &str| -> onemessagebus::Config {
serde_norway::from_str(&format!(
"version: 1\ntransport: {{kind: local}}\nprofile: planner-channel\ncodecs:\n{codecs}"
))
.expect("a configuration")
};
let two = config(
" a: {queue: surfaces, reply_window_seconds: 30, select: op, frames: {}}\n b: {queue: surfaces, reply_window_seconds: 3000, select: op, frames: {}}\n c: {queue: replies, reply_window_seconds: 9000, select: op, frames: {}}\n",
);
assert_eq!(reply_window(Some(&two)), Some(Duration::from_secs(3000)));
let none = config(" a: {queue: surfaces, select: op, frames: {}}\n");
assert_eq!(reply_window(Some(&none)), None);
assert_eq!(reply_window(None), None);
}
}