use crate::paths;
use crate::util::now_rfc3339;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::path::PathBuf;
pub type BErr = Box<dyn std::error::Error + Send + Sync>;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Gate {
pub id: String,
#[serde(alias = "imp")]
pub worker: String,
pub intent: String,
pub executor: String,
pub payload: Value,
#[serde(default)]
pub rationale: String,
#[serde(default)]
pub run_id: String,
#[serde(default)]
pub task_id: String,
pub state: String,
pub filed_at: String,
#[serde(default)]
pub decided_by: Option<String>,
#[serde(default)]
pub decided_at: Option<String>,
#[serde(default)]
pub decision_note: Option<String>,
#[serde(default)]
pub executed_at: Option<String>,
#[serde(default)]
pub result: Option<Value>,
#[serde(default)]
pub error: Option<String>,
}
impl Gate {
pub fn is_terminal(&self) -> bool {
matches!(self.state.as_str(), "executed" | "failed" | "denied")
}
pub fn summary(&self) -> Value {
serde_json::json!({
"id": self.id, "worker": self.worker, "intent": self.intent,
"state": self.state, "filed_at": self.filed_at,
"decided_by": self.decided_by, "decided_at": self.decided_at,
"executed_at": self.executed_at,
})
}
}
fn worker_dirs() -> Vec<PathBuf> {
std::fs::read_dir(paths::workers_data_dir())
.into_iter()
.flatten()
.flatten()
.map(|e| e.path())
.filter(|p| p.is_dir())
.collect()
}
pub fn new_id() -> String {
format!("g-{}", &uuid::Uuid::new_v4().simple().to_string()[..8])
}
pub fn now() -> String {
now_rfc3339()
}
pub fn lock() -> std::io::Result<crate::statefile::FileLock> {
crate::statefile::FileLock::acquire("gates")
}
pub fn save(g: &Gate) -> Result<(), BErr> {
let (dir, other) = if g.is_terminal() {
(
paths::worker_gates_resolved_dir(&g.worker),
paths::worker_gates_pending_dir(&g.worker),
)
} else {
(
paths::worker_gates_pending_dir(&g.worker),
paths::worker_gates_resolved_dir(&g.worker),
)
};
let text = format!("{}\n", serde_json::to_string_pretty(g)?);
crate::statefile::write_atomic(&dir.join(format!("{}.json", g.id)), text.as_bytes())?;
let stale = other.join(format!("{}.json", g.id));
if stale.exists() {
let _ = std::fs::remove_file(stale);
}
Ok(())
}
pub fn load(id: &str) -> Option<Gate> {
for worker in worker_dirs() {
for sub in ["resolved", "pending"] {
if let Ok(s) =
std::fs::read_to_string(worker.join("gates").join(sub).join(format!("{id}.json")))
{
if let Ok(g) = serde_json::from_str::<Gate>(&s) {
return Some(g);
}
}
}
}
None
}
fn read_dir(dir: PathBuf) -> Vec<Gate> {
let mut out: Vec<Gate> = std::fs::read_dir(&dir)
.into_iter()
.flatten()
.flatten()
.filter(|e| e.path().extension().and_then(|x| x.to_str()) == Some("json"))
.filter_map(|e| std::fs::read_to_string(e.path()).ok())
.filter_map(|s| serde_json::from_str::<Gate>(&s).ok())
.collect();
out.sort_by(|a, b| a.filed_at.cmp(&b.filed_at));
out
}
pub fn list_pending() -> Vec<Gate> {
let mut out: Vec<Gate> = worker_dirs()
.into_iter()
.flat_map(|w| read_dir(w.join("gates").join("pending")))
.collect();
out.sort_by(|a, b| a.filed_at.cmp(&b.filed_at));
out
}
pub fn list_all() -> Vec<Gate> {
let mut all: Vec<Gate> = worker_dirs()
.into_iter()
.flat_map(|w| {
let mut gates = read_dir(w.join("gates").join("pending"));
gates.extend(read_dir(w.join("gates").join("resolved")));
gates
})
.collect();
all.sort_by(|a, b| a.filed_at.cmp(&b.filed_at));
all
}
pub fn for_worker(worker: &str) -> Vec<Gate> {
let mut out = read_dir(paths::worker_gates_pending_dir(worker));
out.extend(read_dir(paths::worker_gates_resolved_dir(worker)));
out.sort_by(|a, b| a.filed_at.cmp(&b.filed_at));
out
}
pub fn pending_for_task(task_id: &str) -> Vec<Gate> {
list_pending()
.into_iter()
.filter(|g| g.task_id == task_id)
.collect()
}
pub fn history(worker: &str, intent: &str) -> (u32, u32) {
let mut executed = 0;
let mut denied = 0;
for g in list_all()
.into_iter()
.filter(|g| g.worker == worker && g.intent == intent)
{
match g.state.as_str() {
"executed" => executed += 1,
"denied" => denied += 1,
_ => {}
}
}
(executed, denied)
}
#[cfg(test)]
mod tests {
use super::*;
fn sandbox() -> (std::sync::MutexGuard<'static, ()>, tempfile::TempDir) {
let guard = crate::statefile::TEST_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let dir = tempfile::tempdir().unwrap();
std::env::set_var("ROSTER_ROOT", dir.path());
(guard, dir)
}
fn gate(id: &str, state: &str) -> Gate {
Gate {
id: id.into(),
worker: "w".into(),
intent: "email".into(),
executor: "email".into(),
payload: Value::Null,
rationale: String::new(),
run_id: "r".into(),
task_id: "t".into(),
state: state.into(),
filed_at: now(),
decided_by: None,
decided_at: None,
decision_note: None,
executed_at: None,
result: None,
error: None,
}
}
#[test]
fn load_prefers_resolved_over_a_stale_pending_leftover() {
let (_g, _dir) = sandbox();
let resolved = gate("g-1", "executed");
save(&resolved).unwrap();
let stale_pending = gate("g-1", "executing");
let pdir = paths::worker_gates_pending_dir("w");
std::fs::create_dir_all(&pdir).unwrap();
std::fs::write(
pdir.join("g-1.json"),
serde_json::to_string_pretty(&stale_pending).unwrap(),
)
.unwrap();
assert_eq!(load("g-1").unwrap().state, "executed");
}
}