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(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>,
}
#[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)
.unwrap_or(0);
let claimed = (!queue.waiting.is_empty()).then(|| queue.waiting.remove(next));
if let Some(surface) = &claimed {
if surface.blocking {
queue.pending = Some(surface.clone());
}
}
self.write_queue(&queue)?;
Ok(claimed)
}
pub fn pending(&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,
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)
.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 _ = std::fs::remove_dir_all(&root);
}
}