use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::error::{Error, Result};
use crate::sys;
pub const RUNS_DIR_ENV: &str = "ONEPIPELINE_RUNS_DIR";
pub const DEFAULT_RUNS_DIR: &str = "runs";
pub fn runs_root() -> PathBuf {
std::env::var_os(RUNS_DIR_ENV)
.map(PathBuf::from)
.filter(|p| !p.as_os_str().is_empty())
.unwrap_or_else(|| PathBuf::from(DEFAULT_RUNS_DIR))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RunPaths {
pub run: String,
pub dir: PathBuf,
}
pub fn is_valid_run_id(run: &str) -> bool {
!run.is_empty()
&& run != "."
&& run != ".."
&& !run.contains('/')
&& !run.contains('\\')
&& !Path::new(run).is_absolute()
&& Path::new(run).components().count() == 1
}
fn path_segment(name: &str) -> String {
let mapped: String = name
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' {
c
} else {
'-'
}
})
.collect();
if mapped.is_empty() || mapped.chars().all(|c| c == '.') {
return "unnamed".to_string();
}
mapped
}
impl RunPaths {
pub fn new(run: &str) -> Self {
Self::under(&runs_root(), run)
}
pub fn under(root: &Path, run: &str) -> Self {
Self {
run: run.to_string(),
dir: root.join(run),
}
}
pub fn exists(&self) -> bool {
self.dir.is_dir()
}
pub fn create(&self) -> Result<()> {
fs::create_dir_all(self.channel_dir()).map_err(|e| Error::Ledger {
path: self.channel_dir(),
source: e,
})
}
pub fn journal(&self) -> PathBuf {
self.dir.join("events.jsonl")
}
pub fn launch(&self) -> PathBuf {
self.dir.join("launch.json")
}
pub fn plan(&self) -> PathBuf {
self.dir.join("plan.json")
}
pub fn lock(&self) -> PathBuf {
self.dir.join("owner.lock")
}
pub fn driver_log(&self) -> PathBuf {
self.dir.join("driver.log")
}
pub fn channel_dir(&self) -> PathBuf {
self.dir.join("channel")
}
pub fn reports_dir(&self) -> PathBuf {
self.dir.join("reports")
}
pub fn report_for(&self, stream: &str, seq: u64) -> PathBuf {
self.reports_dir()
.join(format!("{}-{seq}.json", path_segment(stream)))
}
pub fn channel(&self, name: &str) -> PathBuf {
self.channel_dir().join(name)
}
pub fn round_dir(&self, round: u64) -> PathBuf {
self.dir.join(format!("round-{round:02}"))
}
pub fn round_plan(&self, round: u64) -> PathBuf {
self.round_dir(round).join("plan.json")
}
pub fn round_result(&self, round: u64) -> PathBuf {
self.round_dir(round).join("result.json")
}
}
pub fn all_runs(root: &Path) -> Vec<RunPaths> {
let Ok(entries) = fs::read_dir(root) else {
return Vec::new();
};
let mut runs: Vec<RunPaths> = entries
.flatten()
.filter(|e| e.path().is_dir())
.filter(|e| e.path().join("launch.json").is_file())
.filter_map(|e| e.file_name().into_string().ok())
.map(|name| RunPaths::under(root, &name))
.collect();
runs.sort_by(|a, b| a.run.cmp(&b.run));
runs
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct LaunchRecord {
pub run_id: String,
pub plan: PathBuf,
pub graph: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub node_graph: String,
pub launcher: String,
pub session: String,
pub pid: u32,
pub host: String,
pub started_at: String,
pub round_budget: u64,
pub heartbeat_interval: u64,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub dag_sets: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub node_sets: Vec<String>,
#[serde(default)]
pub adoptions: u32,
}
impl LaunchRecord {
pub fn owned_by(&self, session: &str) -> bool {
self.session != sys::UNKNOWN_LAUNCHER && self.session == session
}
pub fn owner_label(&self, session: &str) -> String {
if self.session == sys::UNKNOWN_LAUNCHER {
"[unknown]".to_string()
} else if self.session == session {
"[mine]".to_string()
} else {
format!("[{}:{}]", self.launcher, sys::session_digest(&self.session))
}
}
}
pub fn read_json<T: serde::de::DeserializeOwned>(path: &Path) -> Result<T> {
let text = fs::read_to_string(path).map_err(|e| Error::Ledger {
path: path.to_path_buf(),
source: e,
})?;
serde_json::from_str(&text).map_err(|e| Error::Invalid(format!("{}: {e}", path.display())))
}
pub fn read_json_opt<T: serde::de::DeserializeOwned>(path: &Path) -> Option<T> {
fs::read_to_string(path)
.ok()
.and_then(|text| serde_json::from_str(&text).ok())
}
pub fn write_json<T: Serialize>(path: &Path, value: &T) -> Result<()> {
let body = serde_json::to_string_pretty(value)
.map_err(|e| Error::Invalid(format!("{}: {e}", path.display())))?;
write_atomic(path, body.as_bytes())
}
pub fn write_atomic(path: &Path, bytes: &[u8]) -> Result<()> {
let ledger = |e: io::Error| Error::Ledger {
path: path.to_path_buf(),
source: e,
};
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(ledger)?;
}
let temp = path.with_extension(format!("tmp.{}", sys::pid()));
fs::write(&temp, bytes).map_err(ledger)?;
fs::rename(&temp, path).map_err(ledger)
}
pub fn append_line(path: &Path, line: &str) -> Result<()> {
use std::io::Write;
let ledger = |e: io::Error| Error::Ledger {
path: path.to_path_buf(),
source: e,
};
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(ledger)?;
}
let mut file = fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)
.map_err(ledger)?;
file.write_all(format!("{line}\n").as_bytes())
.map_err(ledger)?;
file.flush().map_err(ledger)
}
pub fn read_lines(path: &Path) -> Vec<String> {
fs::read_to_string(path)
.map(|text| {
text.lines()
.filter(|line| !line.trim().is_empty())
.map(str::to_string)
.collect()
})
.unwrap_or_default()
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct LockRecord {
pub pid: u32,
pub host: String,
pub acquired_at: String,
pub verb: String,
}
#[derive(Debug)]
pub struct OwnershipLock {
path: PathBuf,
held: bool,
}
impl OwnershipLock {
pub fn acquire(paths: &RunPaths, verb: &str) -> Result<Self> {
let path = paths.lock();
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| Error::Ledger {
path: parent.to_path_buf(),
source: e,
})?;
}
let record = LockRecord {
pid: sys::pid(),
host: sys::hostname(),
acquired_at: sys::now_rfc3339(),
verb: verb.to_string(),
};
let body = serde_json::to_string(&record)
.map_err(|e| Error::Invalid(format!("{}: {e}", path.display())))?;
match fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&path)
{
Ok(mut file) => {
use std::io::Write;
file.write_all(body.as_bytes()).map_err(|e| Error::Ledger {
path: path.clone(),
source: e,
})?;
Ok(Self { path, held: true })
}
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {
let held_by: Option<LockRecord> = read_json_opt(&path);
match held_by {
Some(held)
if held.host == sys::hostname() && !sys::process_may_be_live(held.pid) =>
{
write_atomic(&path, body.as_bytes())?;
Ok(Self { path, held: true })
}
Some(held) => Err(Error::Locked {
run: paths.run.clone(),
pid: held.pid,
host: held.host,
verb: held.verb,
}),
None => Err(Error::Locked {
run: paths.run.clone(),
pid: 0,
host: sys::hostname(),
verb: "an unreadable lock".to_string(),
}),
}
}
Err(e) => Err(Error::Ledger { path, source: e }),
}
}
pub fn release(mut self) {
self.remove();
}
fn remove(&mut self) {
if self.held {
let _ = fs::remove_file(&self.path);
self.held = false;
}
}
}
impl Drop for OwnershipLock {
fn drop(&mut self) {
self.remove();
}
}
#[cfg(test)]
mod tests {
use super::*;
fn scratch(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("onepipeline-ledger-{name}-{}", sys::pid()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).expect("a scratch root");
dir
}
#[test]
fn concurrent_appenders_each_land_a_whole_line() {
let root = scratch("append");
let path = root.join("events.jsonl");
const WRITERS: usize = 8;
const EACH: usize = 60;
std::thread::scope(|scope| {
for writer in 0..WRITERS {
let path = path.clone();
scope.spawn(move || {
for n in 0..EACH {
let line = serde_json::json!({
"writer": writer,
"seq": n,
"payload": "x".repeat(512),
})
.to_string();
append_line(&path, &line).expect("the line is appended");
}
});
}
});
let lines = read_lines(&path);
assert_eq!(lines.len(), WRITERS * EACH, "a record was torn or lost");
for line in &lines {
serde_json::from_str::<serde_json::Value>(line)
.unwrap_or_else(|e| panic!("a torn record reached the file: {e}: {line}"));
}
fs::remove_dir_all(&root).ok();
}
#[test]
fn a_lock_refuses_a_second_writer_and_names_the_first() {
let root = scratch("lock");
let paths = RunPaths::under(&root, "demo");
paths.create().expect("the run directory");
let first = OwnershipLock::acquire(&paths, "round run").expect("the first writer wins");
let second = OwnershipLock::acquire(&paths, "round next");
match second {
Err(Error::Locked { run, pid, verb, .. }) => {
assert_eq!(run, "demo");
assert_eq!(pid, sys::pid());
assert_eq!(verb, "round run");
}
other => panic!("a second writer was not refused: {other:?}"),
}
first.release();
OwnershipLock::acquire(&paths, "round next").expect("the lock was released");
fs::remove_dir_all(&root).ok();
}
#[test]
fn a_run_id_names_one_directory_and_never_a_path() {
for good in ["demo", "run-2", "a_b", "tracked-release", "R1"] {
assert!(is_valid_run_id(good), "{good} was refused");
}
for bad in [
"",
".",
"..",
"../elsewhere",
"../../elsewhere",
"a/b",
"a\\b",
"/absolute",
"./here",
] {
assert!(!is_valid_run_id(bad), "{bad:?} was accepted");
}
}
#[test]
fn a_lock_whose_holder_is_proved_gone_is_reclaimed() {
let root = scratch("stale");
let paths = RunPaths::under(&root, "demo");
paths.create().expect("the run directory");
let dead = sys::reaped_pid();
write_json(
&paths.lock(),
&LockRecord {
pid: dead,
host: sys::hostname(),
acquired_at: sys::now_rfc3339(),
verb: "round run".to_string(),
},
)
.expect("a stale lock");
OwnershipLock::acquire(&paths, "round run").expect("a dead holder's lock is reclaimed");
fs::remove_dir_all(&root).ok();
}
#[test]
fn an_unreadable_lock_is_still_a_claim() {
let root = scratch("unreadable");
let paths = RunPaths::under(&root, "demo");
paths.create().expect("the run directory");
fs::write(paths.lock(), "not json at all").expect("a corrupt lock");
assert!(matches!(
OwnershipLock::acquire(&paths, "round run"),
Err(Error::Locked { .. })
));
fs::remove_dir_all(&root).ok();
}
#[test]
fn an_unknown_launch_is_nobodys_run() {
let record = LaunchRecord {
run_id: "demo".into(),
plan: PathBuf::from("plan.json"),
graph: "graphs/dag-scope.yaml".into(),
node_graph: String::new(),
launcher: sys::UNKNOWN_LAUNCHER.into(),
session: sys::UNKNOWN_LAUNCHER.into(),
pid: 1,
host: "h".into(),
started_at: sys::now_rfc3339(),
round_budget: 1,
heartbeat_interval: 1,
dag_sets: Vec::new(),
node_sets: Vec::new(),
adoptions: 0,
};
assert!(!record.owned_by(sys::UNKNOWN_LAUNCHER));
assert_eq!(record.owner_label("anyone"), "[unknown]");
}
#[test]
fn a_foreign_owner_is_labelled_without_naming_the_session() {
let record = LaunchRecord {
run_id: "demo".into(),
plan: PathBuf::from("plan.json"),
graph: "graphs/dag-scope.yaml".into(),
node_graph: String::new(),
launcher: "claude-code".into(),
session: "secret-session-id".into(),
pid: 1,
host: "h".into(),
started_at: sys::now_rfc3339(),
round_budget: 1,
heartbeat_interval: 1,
dag_sets: Vec::new(),
node_sets: Vec::new(),
adoptions: 0,
};
let label = record.owner_label("mine");
assert!(!label.contains("secret-session-id"), "{label} leaks the id");
assert!(label.starts_with("[claude-code:"));
assert_eq!(record.owner_label("secret-session-id"), "[mine]");
assert!(record.owned_by("secret-session-id"));
}
#[test]
fn an_atomic_write_leaves_no_temporary_behind() {
let root = scratch("atomic");
let target = root.join("nested").join("record.json");
write_json(&target, &serde_json::json!({"ok": true})).expect("written");
let value: serde_json::Value = read_json(&target).expect("read back");
assert_eq!(value["ok"], serde_json::json!(true));
let leftovers: Vec<_> = fs::read_dir(root.join("nested"))
.expect("the directory")
.flatten()
.filter(|e| e.file_name().to_string_lossy().contains("tmp"))
.collect();
assert!(leftovers.is_empty(), "a temporary survived the rename");
fs::remove_dir_all(&root).ok();
}
#[test]
fn appended_lines_read_back_in_order_and_skip_blanks() {
let root = scratch("append");
let path = root.join("queue.jsonl");
assert!(read_lines(&path).is_empty());
append_line(&path, "first").expect("appended");
append_line(&path, "").expect("appended");
append_line(&path, "second").expect("appended");
assert_eq!(read_lines(&path), vec!["first", "second"]);
fs::remove_dir_all(&root).ok();
}
#[test]
fn only_directories_with_a_launch_record_are_runs() {
let root = scratch("index");
for name in ["b-run", "a-run"] {
let paths = RunPaths::under(&root, name);
paths.create().expect("a run directory");
write_json(&paths.launch(), &serde_json::json!({})).expect("a launch record");
}
fs::create_dir_all(root.join("scratch")).expect("a non-run directory");
let ids: Vec<String> = all_runs(&root).into_iter().map(|r| r.run).collect();
assert_eq!(ids, vec!["a-run".to_string(), "b-run".to_string()]);
assert!(all_runs(&root.join("missing")).is_empty());
fs::remove_dir_all(&root).ok();
}
}