use std::fs;
use std::fs::{File, OpenOptions};
use std::io;
use std::path::{Path, PathBuf};
use std::sync::mpsc::Receiver;
use std::time::Duration;
use crate::event::WorkflowEvent;
use crate::writer::JournalWriter;
const HOST_FLUSH_TIMEOUT: Duration = Duration::from_secs(5);
pub struct Journal {
dir: PathBuf,
writer: JournalWriter,
}
impl Journal {
pub fn open(runs_root: impl AsRef<Path>, run_id: &str) -> io::Result<Self> {
Self::open_named(runs_root, run_id, "events.jsonl")
}
pub fn open_named(
runs_root: impl AsRef<Path>,
run_id: &str,
filename: &str,
) -> io::Result<Self> {
let dir = runs_root.as_ref().join(run_id);
ensure_run_dir(&dir)?;
let path = dir.join(filename);
let file = OpenOptions::new()
.write(true)
.create_new(true)
.open(&path)?;
Ok(Self::from_open(dir, file))
}
fn from_open(dir: PathBuf, file: File) -> Self {
let writer = JournalWriter::spawn(dir.clone(), file);
Self { dir, writer }
}
pub fn write_memo(&self, content_key: &str, value: &serde_json::Value) {
let mut json = serde_json::to_string(value).unwrap_or_else(|_| "null".to_string());
json.push('\n');
self.writer.enqueue_memo(content_key.to_string(), json);
}
#[doc(hidden)]
pub fn null() -> Self {
let mut dir = std::env::temp_dir();
dir.push(format!(
"sema-wf-null-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
));
Self::open(&dir, "null").unwrap_or_else(|_| {
let f = OpenOptions::new()
.append(true)
.create(true)
.open(std::env::temp_dir().join("sema-wf-null.jsonl"))
.expect("temp dir is writable for the null journal");
Self::from_open(dir, f)
})
}
pub fn dir(&self) -> &Path {
&self.dir
}
pub fn request_flush(&self) -> Receiver<()> {
self.writer.request_flush()
}
pub fn flush_blocking(&self) {
let ack = self.writer.request_flush();
let _ = ack.recv_timeout(HOST_FLUSH_TIMEOUT);
}
pub fn write(&self, event: &WorkflowEvent) {
let line = match serde_json::to_string(event) {
Ok(s) => s,
Err(e) => format!("{{\"error\":\"workflow journal serialize: {e}\"}}"),
};
self.writer.enqueue_event(line);
}
pub fn write_args(&self, args: &serde_json::Value) {
self.write_sidecar("args.json", args);
}
pub fn write_metadata(&self, metadata: &serde_json::Value) {
self.write_sidecar("metadata.json", metadata);
}
pub fn write_result(&self, result: &serde_json::Value) {
self.write_sidecar("result.json", result);
}
fn write_sidecar(&self, name: &str, value: &serde_json::Value) {
let mut s = serde_json::to_string_pretty(value)
.unwrap_or_else(|e| format!("{{\"error\":\"workflow {name} serialize: {e}\"}}"));
s.push('\n');
self.writer.enqueue_sidecar(name.to_string(), s);
}
}
fn ensure_run_dir(dir: &Path) -> io::Result<()> {
if let Some(parent) = dir.parent() {
fs::create_dir_all(parent)?;
}
match fs::create_dir(dir) {
Ok(()) => Ok(()),
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => Ok(()),
Err(e) => Err(e),
}
}
pub fn load_memos(runs_root: impl AsRef<Path>, run_id: &str) -> Vec<(String, serde_json::Value)> {
let memo_dir = runs_root.as_ref().join(run_id).join("memo");
let mut out = Vec::new();
let Ok(entries) = fs::read_dir(&memo_dir) else {
return out;
};
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("json") {
continue;
}
let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
continue;
};
if let Ok(text) = fs::read_to_string(&path) {
if let Ok(json) = serde_json::from_str::<serde_json::Value>(&text) {
out.push((stem.to_string(), json));
}
}
}
out
}
pub fn next_resume_segment(runs_root: impl AsRef<Path>, run_id: &str) -> io::Result<Journal> {
let dir = runs_root.as_ref().join(run_id);
let mut n: u32 = 1;
loop {
let path = dir.join(format!("events.resume-{n}.jsonl"));
match OpenOptions::new().write(true).create_new(true).open(&path) {
Ok(file) => {
return Ok(Journal::from_open(dir, file));
}
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {
n = n.checked_add(1).ok_or_else(|| {
io::Error::new(
io::ErrorKind::AlreadyExists,
"resume segment ordinal space exhausted",
)
})?;
}
Err(e) => return Err(e),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn tmp_root() -> PathBuf {
let mut p = std::env::temp_dir();
p.push(format!(
"sema-wf-journal-test-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
p
}
#[test]
fn writes_events_jsonl_one_line_per_event() {
let root = tmp_root();
let j = Journal::open(&root, "wf_test_0001").unwrap();
j.write(&WorkflowEvent::RunStarted {
seq: 0,
ts: "0".into(),
workflow: "hello-wf".into(),
run_id: "wf_test_0001".into(),
code_version: String::new(),
args_json: String::new(),
phases: Vec::new(),
});
j.write(&WorkflowEvent::RunEnded {
seq: 1,
ts: "0".into(),
status: "success".into(),
reason: None,
dur_ms: 0,
});
j.flush_blocking(); drop(j);
let body = fs::read_to_string(root.join("wf_test_0001").join("events.jsonl")).unwrap();
let lines: Vec<&str> = body.lines().collect();
assert_eq!(lines.len(), 2);
assert!(lines[0].starts_with(r#"{"event":"run.started""#));
assert!(lines[1].starts_with(r#"{"event":"run.ended""#));
assert!(body.ends_with('\n'));
fs::remove_dir_all(&root).ok();
}
#[test]
fn sidecars_land_in_run_dir() {
let root = tmp_root();
let j = Journal::open(&root, "wf_test_0002").unwrap();
j.write_args(&serde_json::json!({"name": "x"}));
j.write_result(&serde_json::json!({"status": "success"}));
j.flush_blocking();
assert!(j.dir().join("args.json").exists());
assert!(j.dir().join("result.json").exists());
fs::remove_dir_all(&root).ok();
}
#[test]
fn open_fresh_fails_when_journal_already_exists() {
let root = tmp_root();
let _first = Journal::open(&root, "wf_dupe").unwrap();
let err = match Journal::open(&root, "wf_dupe") {
Ok(_) => panic!("second fresh open must fail"),
Err(e) => e,
};
assert_eq!(err.kind(), io::ErrorKind::AlreadyExists);
fs::remove_dir_all(&root).ok();
}
#[test]
fn open_fresh_adopts_a_pre_existing_dir_without_a_journal() {
let root = tmp_root();
fs::create_dir_all(root.join("wf_seeded").join("auth")).unwrap();
let j = Journal::open(&root, "wf_seeded").expect("adopt a dir with no journal");
assert!(j.dir().join("events.jsonl").exists());
assert!(j.dir().join("auth").is_dir(), "seeded content is preserved");
fs::remove_dir_all(&root).ok();
}
#[test]
fn concurrent_resume_segment_claims_are_distinct() {
use std::sync::{Arc, Barrier};
let root = tmp_root();
let run = "wf_race";
fs::create_dir_all(root.join(run)).unwrap();
let barrier = Arc::new(Barrier::new(2));
let mut handles = Vec::new();
for _ in 0..2 {
let root = root.clone();
let barrier = Arc::clone(&barrier);
handles.push(std::thread::spawn(move || {
barrier.wait();
let journal = next_resume_segment(&root, run).expect("segment claim succeeds");
journal.write(&WorkflowEvent::RunEnded {
seq: 0,
ts: "0".into(),
status: "success".into(),
reason: None,
dur_ms: 0,
});
journal.flush_blocking();
}));
}
for h in handles {
h.join().unwrap();
}
let mut names: Vec<String> = fs::read_dir(root.join(run))
.unwrap()
.flatten()
.filter_map(|e| e.file_name().into_string().ok())
.filter(|n| n.starts_with("events.resume-"))
.collect();
names.sort();
assert_eq!(
names,
vec![
"events.resume-1.jsonl".to_string(),
"events.resume-2.jsonl".to_string()
],
"concurrent resumes must claim two distinct segments"
);
fs::remove_dir_all(&root).ok();
}
}