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 = 2;
#[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, 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,
},
}
#[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>,
}
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 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::*;
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);
}
}