use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use crate::plan::Node;
pub const REPLY_ENVELOPE_VERSION: u32 = 1;
#[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",
}
}
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::Context { .. }
| 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"
}
};
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::Context { .. } => "context",
}
}
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::Context { id, .. } => Some(id.clone()),
Command::Attest { reference } => Some(reference.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>,
}
#[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,
},
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,
},
Context {
id: String,
note: String,
#[serde(default, skip_serializing_if = "Deliver::is_auto")]
deliver: Deliver,
},
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Deliver {
#[default]
Auto,
Live,
Next,
}
impl Deliver {
fn is_auto(&self) -> bool {
matches!(self, Self::Auto)
}
}
#[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,
}
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, 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 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 claimed = (!queue.waiting.is_empty()).then(|| queue.waiting.remove(0));
if let Some(surface) = &claimed {
queue.pending = surface.blocking.then(|| 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 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_replies(&self) -> crate::Result<Vec<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 fresh: Vec<QueuedReply> = self
.replies()
.into_iter()
.filter(|reply| reply.id >= claimed_through)
.collect();
if let Some(last) = fresh.last() {
crate::ledger::write_json(&cursor_path, &(last.id + 1))?;
}
Ok(fresh)
}
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)
}
}