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 = 3;
pub const REPLY_ENVELOPE_VERSIONS_READ: &[u32] = &[REPLY_ENVELOPE_VERSION, 2];
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| {
if REPLY_ENVELOPE_VERSIONS_READ.contains(&version) {
REPLY_ENVELOPE_VERSION
} else {
version
}
}))
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Author {
#[default]
Planner,
Monitor,
}
impl Author {
pub fn as_str(self) -> &'static str {
match self {
Self::Planner => "planner",
Self::Monitor => "monitor",
}
}
pub(crate) fn is_planner(&self) -> bool {
matches!(self, Self::Planner)
}
}
pub fn allows_completion(author: Author, completion: Option<bool>) -> crate::Result<()> {
if author == Author::Planner || completion != Some(true) {
return Ok(());
}
Err(crate::Error::Refused(
"declaring the run complete is not something the monitor may do: whether the run \
is finished is the planner's verdict, not an observation. Surface it to the \
planner instead"
.to_string(),
))
}
pub fn allows(author: Author, command: &Command) -> crate::Result<()> {
if author == Author::Planner {
return Ok(());
}
let refused = match command {
Command::Retry { .. }
| Command::Requeue { .. }
| Command::Cancel { .. }
| Command::Finding { .. }
| Command::Add { .. } => return Ok(()),
Command::Complete { .. } => {
"whether the run is finished is the planner's verdict, not an observation"
}
Command::Attest { .. } => {
"a human action is attested by the person who took it, never by a watcher"
}
Command::Drop { .. } => {
"removing work from the graph is a decomposition decision the planner owns"
}
Command::Reparent { .. } => {
"rewiring dependencies is a decomposition decision the planner owns"
}
Command::Amend { .. } => {
"what a node is judged against is a decomposition decision the planner owns"
}
Command::Note { .. } => {
"a note may bind a criterion the node's judge decides against, which is the \
planner's decision rather than an observation"
}
Command::Settle { .. } => {
"settling a node from evidence declares an outcome this run never observed, \
which is the planner's decision rather than an observation"
}
};
Err(crate::Error::Refused(format!(
"'{}' is not an op the monitor may issue: {refused}. Surface it to the planner instead",
op_of(command)
)))
}
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)]
#[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 Reply {
pub(crate) fn carries_verdict(&self) -> bool {
self.completion.is_some() || self.message.is_some() || self.reason.is_some()
}
pub(crate) fn carries_edits_without_a_verdict(&self) -> bool {
!self.commands.is_empty() && !self.carries_verdict()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Dependents {
Drop,
Detach,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "op", rename_all = "lowercase", deny_unknown_fields)]
pub enum Command {
Add {
node: Node,
},
Drop {
id: String,
dependents: Dependents,
},
Reparent {
id: String,
deps: Vec<String>,
},
Retry {
id: String,
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>,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[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)]
#[serde(rename_all = "lowercase")]
pub enum Deliver {
#[default]
Live,
Next,
}
impl Deliver {
fn is_default(&self) -> bool {
matches!(self, Self::Live)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, clap::ValueEnum)]
#[serde(rename_all = "kebab-case")]
#[value(rename_all = "kebab-case")]
#[non_exhaustive]
pub enum SurfaceKind {
CheckIn,
Finding,
}
impl SurfaceKind {
pub(crate) fn as_str(self) -> &'static str {
match self {
Self::CheckIn => "check-in",
Self::Finding => "finding",
}
}
}
pub const REPLY_TIMEOUT_ENV: &str = "ONEPIPELINE_REPLY_TIMEOUT_SECONDS";
pub const DEFAULT_REPLY_TIMEOUT_SECONDS: u64 = 30;
pub const SERVE_SESSION_ENV: &str = "ONEPIPELINE_SERVE_SESSION_SECONDS";
pub const ASKER_ENV: &str = "ONEPIPELINE_CHANNEL_ASKER";
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(transparent)]
pub(crate) struct Asker(String);
impl Asker {
pub(crate) fn named(value: &std::ffi::OsStr) -> crate::Result<Self> {
let value = value.to_str().ok_or_else(|| {
crate::Error::Refused(format!(
"{ASKER_ENV} is set to a value this host cannot read as text; an asker is \
compared to other askers as one word, and two values that are not text read \
as the same word — set it to a name in Unicode, or leave it unset for a \
session that listens on its own"
))
})?;
Self::checked(value)
}
fn checked(value: &str) -> crate::Result<Self> {
if value.trim().is_empty() {
return Err(crate::Error::Refused(format!(
"{ASKER_ENV} is set to a blank value, which names no asker; leave it unset for \
a session that listens on its own, or set it to the one value every session \
of this asker carries"
)));
}
Ok(Self(value.to_owned()))
}
}
fn recorded_asker<'de, D: serde::Deserializer<'de>>(
deserializer: D,
) -> Result<Option<Asker>, D::Error> {
Ok(Option::<String>::deserialize(deserializer)?.and_then(|name| Asker::checked(&name).ok()))
}
pub(crate) mod source {
pub const CHECK_IN: &str = "check-in";
pub const PROPOSAL: &str = "proposal";
pub const RECONCILER: &str = "reconciler";
pub const MONITOR: &str = "monitor";
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
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>,
}
#[derive(Debug, Clone)]
pub(crate) struct ChannelState {
paths: crate::ledger::RunPaths,
}
#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
pub(crate) struct Queue {
#[serde(default)]
pub waiting: Vec<Surface>,
#[serde(default)]
pub pending: Option<Surface>,
#[serde(default)]
pub next_id: u64,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub(crate) struct QueuedReply {
pub id: u64,
pub reply: Reply,
pub at: u64,
}
#[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, Default, PartialEq, Eq)]
pub(crate) struct Fingerprint {
queue: Option<(u64, std::time::SystemTime)>,
commands: Option<(u64, std::time::SystemTime)>,
}
fn mark(path: &std::path::Path) -> Option<(u64, std::time::SystemTime)> {
let metadata = std::fs::metadata(path).ok()?;
Some((
metadata.len(),
metadata
.modified()
.unwrap_or(std::time::SystemTime::UNIX_EPOCH),
))
}
#[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,
}
impl ChannelState {
pub fn new(paths: &crate::ledger::RunPaths) -> Self {
Self {
paths: paths.clone(),
}
}
fn queue_path(&self) -> std::path::PathBuf {
self.paths.channel("queue.json")
}
pub(crate) fn fingerprint(&self) -> Fingerprint {
Fingerprint {
queue: mark(&self.queue_path()),
commands: mark(&self.paths.channel("commands.jsonl")),
}
}
pub fn queue(&self) -> Queue {
crate::ledger::read_json_opt(&self.queue_path()).unwrap_or_default()
}
fn write_queue(&self, queue: &Queue) -> crate::Result<()> {
crate::ledger::write_json(&self.queue_path(), queue)
}
pub fn push(&self, mut surface: Surface) -> crate::Result<Surface> {
let mut queue = self.queue();
surface.id = queue.next_id;
queue.next_id += 1;
if surface.source == source::CHECK_IN {
queue
.waiting
.retain(|existing| existing.source != source::CHECK_IN);
}
queue.waiting.push(surface.clone());
self.write_queue(&queue)?;
crate::ledger::append_line(
&self.paths.channel("surfaces.jsonl"),
&serde_json::to_string(&surface)
.map_err(|e| crate::Error::Invalid(format!("surface: {e}")))?,
)?;
Ok(surface)
}
pub fn claim(&self) -> crate::Result<Option<Surface>> {
let mut queue = self.queue();
let next = queue
.waiting
.iter()
.position(|surface| surface.blocking && !surface.abandoned)
.or_else(|| queue.waiting.iter().position(|surface| !surface.abandoned))
.unwrap_or(0);
let claimed = (!queue.waiting.is_empty()).then(|| queue.waiting.remove(next));
if let Some(surface) = &claimed {
if surface.blocking && (!surface.abandoned || queue.pending.is_none()) {
if let Some(displaced) = queue.pending.replace(surface.clone()) {
if displaced.abandoned {
queue.waiting.push(displaced);
}
}
}
}
self.write_queue(&queue)?;
Ok(claimed)
}
pub fn abandon(&self, raised: &[u64]) -> crate::Result<Vec<Surface>> {
let mut queue = self.queue();
let mut marked: Vec<Surface> = Vec::new();
for surface in queue.waiting.iter_mut().chain(queue.pending.iter_mut()) {
if raised.contains(&surface.id) && !surface.abandoned {
surface.abandoned = true;
marked.push(surface.clone());
}
}
if marked.is_empty() {
return Ok(marked);
}
self.write_queue(&queue)?;
for surface in &marked {
crate::ledger::append_line(
&self.paths.channel("surfaces.jsonl"),
&serde_json::to_string(surface)
.map_err(|e| crate::Error::Invalid(format!("surface: {e}")))?,
)?;
}
Ok(marked)
}
pub fn attend(&self, asker: &Asker) -> crate::Result<Vec<Surface>> {
let mut queue = self.queue();
let mut taken: Vec<Surface> = Vec::new();
for surface in queue.waiting.iter_mut().chain(queue.pending.iter_mut()) {
if surface.abandoned && surface.asker.as_ref() == Some(asker) {
surface.abandoned = false;
taken.push(surface.clone());
}
}
if taken.is_empty() {
return Ok(taken);
}
self.write_queue(&queue)?;
for surface in &taken {
crate::ledger::append_line(
&self.paths.channel("surfaces.jsonl"),
&serde_json::to_string(surface)
.map_err(|e| crate::Error::Invalid(format!("surface: {e}")))?,
)?;
}
Ok(taken)
}
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> {
let mut queue = self.queue();
queue.pending = None;
self.write_queue(&queue)?;
let path = self.paths.channel("replies.jsonl");
let id = crate::ledger::read_lines(&path).len() as u64;
let queued = QueuedReply {
id,
reply: reply.clone(),
at: crate::sys::now_millis(),
};
crate::ledger::append_line(
&path,
&serde_json::to_string(&queued)
.map_err(|e| crate::Error::Invalid(format!("reply: {e}")))?,
)?;
Ok(id)
}
pub fn answer_if_verdict(&self, reply: &Reply) -> crate::Result<()> {
if reply.carries_verdict() {
self.answer(reply)?;
}
Ok(())
}
pub fn replies(&self) -> Vec<QueuedReply> {
crate::ledger::read_lines(&self.paths.channel("replies.jsonl"))
.iter()
.filter_map(|line| serde_json::from_str(line).ok())
.collect()
}
pub fn claim_reply(&self) -> crate::Result<Option<QueuedReply>> {
let cursor_path = self.paths.channel("replies-cursor.json");
let claimed_through: u64 = crate::ledger::read_json_opt(&cursor_path).unwrap_or(0);
let claimed = self.replies().into_iter().find(|queued| {
queued.id >= claimed_through && !queued.reply.carries_edits_without_a_verdict()
});
if let Some(claimed) = &claimed {
crate::ledger::write_json(&cursor_path, &(claimed.id + 1))?;
}
Ok(claimed)
}
pub fn submit(&self, author: Author, commands: &[Command]) -> crate::Result<u64> {
let path = self.paths.channel("commands.jsonl");
let id = crate::ledger::read_lines(&path).len() as u64;
let queued = QueuedCommands {
id,
author,
commands: commands.to_vec(),
};
crate::ledger::append_line(
&path,
&serde_json::to_string(&queued)
.map_err(|e| crate::Error::Invalid(format!("commands: {e}")))?,
)?;
Ok(id)
}
pub(crate) fn claimable_commands(&self) -> Vec<QueuedCommands> {
let claimed_through: u64 =
crate::ledger::read_json_opt(&self.paths.channel("commands-cursor.json")).unwrap_or(0);
crate::ledger::read_lines(&self.paths.channel("commands.jsonl"))
.iter()
.filter_map(|line| serde_json::from_str::<QueuedCommands>(line).ok())
.filter(|queued| queued.id >= claimed_through)
.collect()
}
pub fn claim_commands(&self) -> crate::Result<Vec<QueuedCommands>> {
let cursor_path = self.paths.channel("commands-cursor.json");
let claimed_through: u64 = crate::ledger::read_json_opt(&cursor_path).unwrap_or(0);
let fresh: Vec<QueuedCommands> =
crate::ledger::read_lines(&self.paths.channel("commands.jsonl"))
.iter()
.filter_map(|line| serde_json::from_str::<QueuedCommands>(line).ok())
.filter(|queued| queued.id >= claimed_through)
.collect();
if let Some(last) = fresh.last() {
crate::ledger::write_json(&cursor_path, &(last.id + 1))?;
}
Ok(fresh)
}
pub fn answer_commands(&self, outcome: &CommandOutcome) -> crate::Result<()> {
crate::ledger::append_line(
&self.paths.channel("command-outcomes.jsonl"),
&serde_json::to_string(outcome)
.map_err(|e| crate::Error::Invalid(format!("outcome: {e}")))?,
)
}
pub fn outcome_of(&self, id: u64) -> Option<CommandOutcome> {
crate::ledger::read_lines(&self.paths.channel("command-outcomes.jsonl"))
.iter()
.filter_map(|line| serde_json::from_str::<CommandOutcome>(line).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),
}
};
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 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, .. } = 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"
);
}
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"
);
}
fn surface(id: u64, blocking: bool) -> Surface {
Surface {
id,
kind: "finding".to_owned(),
message: "something happened".to_owned(),
source: source::PROPOSAL.to_owned(),
blocking,
queued_at: 0,
abandoned: false,
asker: None,
workstream: Some("ship".to_owned()),
}
}
#[test]
fn every_queue_change_the_loop_reads_shows_in_its_length() {
let root =
std::env::temp_dir().join(format!("onepipeline-queuemark-{}", crate::sys::pid()));
let _ = std::fs::remove_dir_all(&root);
let paths = crate::ledger::RunPaths::under(&root, "marks");
paths.create().expect("the run directory");
let channel = ChannelState::new(&paths);
let outstanding = |channel: &ChannelState| -> Vec<u64> {
let queue = channel.queue();
queue
.waiting
.iter()
.chain(queue.pending.iter())
.filter(|surface| surface.blocking && !surface.abandoned)
.map(|surface| surface.id)
.collect()
};
let length = |channel: &ChannelState| -> Option<u64> {
mark(&channel.queue_path()).map(|(bytes, _)| bytes)
};
let empty = length(&channel);
let pushed = channel.push(surface(0, true)).expect("a surface is queued");
let queued = length(&channel);
assert_ne!(empty, queued, "a queued surface did not change the length");
assert_eq!(outstanding(&channel), vec![pushed.id]);
let claimed = channel.claim().expect("the surface is claimed");
assert!(claimed.is_some());
assert_eq!(
outstanding(&channel),
vec![pushed.id],
"a claimed blocking surface stopped being outstanding"
);
channel
.answer(&Reply {
completion: None,
commands: Vec::new(),
..Reply::default()
})
.expect("the surface is answered");
assert_ne!(
length(&channel),
queued,
"an answered surface did not change the length"
);
assert_eq!(outstanding(&channel), Vec::<u64>::new());
let second = channel.push(surface(0, true)).expect("a surface is queued");
let waiting = length(&channel);
assert_eq!(outstanding(&channel), vec![second.id]);
let marked = channel
.abandon(&[second.id])
.expect("the surface is marked");
assert_eq!(marked.len(), 1, "{marked:?}");
assert!(marked[0].abandoned);
assert_ne!(
length(&channel),
waiting,
"an abandoned surface did not change the length"
);
assert_eq!(outstanding(&channel), Vec::<u64>::new());
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn abandoning_keeps_every_surface_readable_and_leaves_the_slot_holding_its_own() {
let root = std::env::temp_dir().join(format!("onepipeline-abandon-{}", crate::sys::pid()));
let _ = std::fs::remove_dir_all(&root);
let paths = crate::ledger::RunPaths::under(&root, "gone");
paths.create().expect("the run directory");
let channel = ChannelState::new(&paths);
let read = channel.push(surface(0, true)).expect("the question queues");
let unread = channel
.push(Surface {
message: "nobody has seen this".to_owned(),
..surface(0, false)
})
.expect("the narration queues");
channel.claim().expect("a claim").expect("a surface");
assert_eq!(channel.pending().map(|held| held.id), Some(read.id));
let marked = channel
.abandon(&[read.id, unread.id])
.expect("both are marked");
assert_eq!(marked.len(), 2, "{marked:?}");
assert!(marked.iter().all(|surface| surface.abandoned));
assert_eq!(channel.pending(), None);
assert_eq!(channel.held().map(|held| held.id), Some(read.id));
assert!(channel.held().is_some_and(|held| held.abandoned));
let queue = channel.queue();
assert_eq!(queue.waiting.len(), 1, "{queue:?}");
let mut messages: Vec<String> = queue
.waiting
.iter()
.chain(queue.pending.iter())
.map(|surface| surface.message.clone())
.collect();
messages.sort();
assert_eq!(messages, vec!["nobody has seen this", "something happened"]);
let first = channel.claim().expect("a claim").expect("a surface");
assert_eq!(first.id, unread.id);
assert!(first.abandoned);
assert_eq!(channel.pending(), None);
assert_eq!(channel.claim().expect("a claim"), None);
let logged: Vec<Surface> = crate::ledger::read_lines(&paths.channel("surfaces.jsonl"))
.iter()
.filter_map(|line| serde_json::from_str(line).ok())
.collect();
for id in [read.id, unread.id] {
assert!(
logged
.iter()
.any(|surface| surface.id == id && surface.abandoned),
"no record that surface {id} was abandoned: {logged:?}"
);
}
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn a_recorded_asker_that_names_nobody_reads_as_nobody() {
let raised = Surface {
asker: Some(Asker::checked("dispatch-a").expect("a name")),
..surface(7, true)
};
let written = serde_json::to_string(&raised).expect("the surface writes");
assert!(written.contains(r#""asker":"dispatch-a""#), "{written}");
let read: Surface = serde_json::from_str(&written).expect("the surface reads back");
assert_eq!(read.asker, raised.asker);
for recorded in [r##","asker":"""##, ""] {
let line = written.replace(r#","asker":"dispatch-a""#, recorded);
let read: Surface = serde_json::from_str(&line)
.unwrap_or_else(|e| panic!("a queue recording {recorded:?} was lost: {e}"));
assert_eq!(read.asker, None, "{line}");
assert_eq!(read.message, raised.message);
}
}
#[test]
fn a_live_question_takes_the_slot_and_the_abandoned_one_it_displaces_stays_readable() {
let root = std::env::temp_dir().join(format!("onepipeline-displace-{}", crate::sys::pid()));
let _ = std::fs::remove_dir_all(&root);
let paths = crate::ledger::RunPaths::under(&root, "displaced");
paths.create().expect("the run directory");
let channel = ChannelState::new(&paths);
let gone = channel.push(surface(0, true)).expect("the question queues");
channel.claim().expect("a claim").expect("a surface");
channel.abandon(&[gone.id]).expect("it is marked");
assert_eq!(channel.held().map(|held| held.id), Some(gone.id));
let live = channel
.push(Surface {
message: "somebody is waiting on this".to_owned(),
..surface(0, true)
})
.expect("the live question queues");
let claimed = channel.claim().expect("a claim").expect("a surface");
assert_eq!(claimed.id, live.id);
assert_eq!(channel.pending().map(|held| held.id), Some(live.id));
let queue = channel.queue();
assert_eq!(
queue
.waiting
.iter()
.map(|surface| (surface.id, surface.message.as_str()))
.collect::<Vec<_>>(),
vec![(gone.id, "something happened")],
"{queue:?}"
);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn attending_takes_back_one_askers_surfaces_and_leaves_every_other_alone() {
let root = std::env::temp_dir().join(format!("onepipeline-attend-{}", crate::sys::pid()));
let _ = std::fs::remove_dir_all(&root);
let paths = crate::ledger::RunPaths::under(&root, "back");
paths.create().expect("the run directory");
let channel = ChannelState::new(&paths);
let asked = |asker: Option<&str>, blocking: bool, message: &str| Surface {
message: message.to_owned(),
asker: asker.map(|name| Asker::checked(name).expect("a name")),
..surface(0, blocking)
};
let mine = channel
.push(asked(Some("dispatch-a"), true, "is this base still right?"))
.expect("the question queues");
let theirs = channel
.push(asked(Some("dispatch-b"), true, "somebody else's question"))
.expect("their question queues");
let nobodys = channel
.push(asked(None, false, "raised by no session"))
.expect("the narration queues");
channel.claim().expect("a claim").expect("a surface");
assert_eq!(channel.pending().map(|held| held.id), Some(mine.id));
channel
.abandon(&[mine.id, theirs.id, nobodys.id])
.expect("all three are marked");
assert_eq!(channel.pending(), None);
let named = |name: &str| Asker::checked(name).expect("a name");
let taken = channel
.attend(&named("dispatch-a"))
.expect("mine comes back");
assert_eq!(
taken.iter().map(|surface| surface.id).collect::<Vec<_>>(),
vec![mine.id]
);
assert_eq!(channel.pending().map(|held| held.id), Some(mine.id));
let queue = channel.queue();
assert!(
queue
.waiting
.iter()
.all(|surface| surface.abandoned && surface.id != mine.id),
"attending took a surface belonging to another asker: {queue:?}"
);
let lines = crate::ledger::read_lines(&paths.channel("surfaces.jsonl")).len();
assert!(channel
.attend(&named("dispatch-a"))
.expect("nothing")
.is_empty());
assert!(channel
.attend(&named("dispatch-c"))
.expect("nothing")
.is_empty());
assert_eq!(
crate::ledger::read_lines(&paths.channel("surfaces.jsonl")).len(),
lines,
"attending what was already attended wrote a second record"
);
let logged: Vec<Surface> = crate::ledger::read_lines(&paths.channel("surfaces.jsonl"))
.iter()
.filter_map(|line| serde_json::from_str(line).ok())
.collect();
assert!(
logged.iter().any(|surface| surface.id == mine.id
&& !surface.abandoned
&& surface.asker.is_some()),
"no record that surface {} was taken back: {logged:?}",
mine.id
);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn abandoning_what_is_already_abandoned_records_nothing_further() {
let root =
std::env::temp_dir().join(format!("onepipeline-reabandon-{}", crate::sys::pid()));
let _ = std::fs::remove_dir_all(&root);
let paths = crate::ledger::RunPaths::under(&root, "twice");
paths.create().expect("the run directory");
let channel = ChannelState::new(&paths);
let queued = channel.push(surface(0, true)).expect("the surface queues");
assert_eq!(channel.abandon(&[queued.id]).expect("marked").len(), 1);
let lines = crate::ledger::read_lines(&paths.channel("surfaces.jsonl")).len();
assert!(channel.abandon(&[queued.id]).expect("nothing").is_empty());
assert!(channel.abandon(&[]).expect("nothing").is_empty());
assert_eq!(
crate::ledger::read_lines(&paths.channel("surfaces.jsonl")).len(),
lines,
"abandoning the same surface twice wrote a second record"
);
let _ = std::fs::remove_dir_all(&root);
}
}