loopd: job files, run ledger, jobs-invalid
Co-Authored-By: ornith-1.5-35b-a3b <agent@wntrmute.dev>
This commit is contained in:
@@ -0,0 +1,193 @@
|
||||
//! Owner-written job files and the run ledger (M5d spec, section 4.2).
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::io::{ErrorKind, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use bxcore::{SessionId, Timestamp};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Job {
|
||||
pub name: String,
|
||||
pub schedule: String,
|
||||
pub prompt: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RunStatus {
|
||||
Started,
|
||||
Finished,
|
||||
Failed,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Run {
|
||||
pub time: Timestamp,
|
||||
pub name: String,
|
||||
pub session: SessionId,
|
||||
pub status: RunStatus,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum JobsError {
|
||||
Name(String),
|
||||
Invalid { path: PathBuf, detail: String },
|
||||
}
|
||||
|
||||
impl std::fmt::Display for JobsError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
JobsError::Name(name) => write!(
|
||||
f,
|
||||
"job name {name:?} is not a valid job; see docs/runbook.md#jobs-invalid"
|
||||
),
|
||||
JobsError::Invalid { path, detail } => write!(
|
||||
f,
|
||||
"{}: {detail}; see docs/runbook.md#jobs-invalid",
|
||||
path.display()
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for JobsError {}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct File {
|
||||
schedule: String,
|
||||
prompt: String,
|
||||
}
|
||||
|
||||
fn invalid(path: &Path, detail: impl std::fmt::Display) -> JobsError {
|
||||
JobsError::Invalid {
|
||||
path: path.to_path_buf(),
|
||||
detail: detail.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn session_id(name: &str, now: Timestamp) -> Result<SessionId, JobsError> {
|
||||
let rfc = now.to_rfc3339();
|
||||
let ymd = format!("{}{}{}", &rfc[0..4], &rfc[5..7], &rfc[8..10]);
|
||||
let hm = format!("{}{}", &rfc[11..13], &rfc[14..16]);
|
||||
let id = format!("job-{name}-{ymd}t{hm}");
|
||||
SessionId::new(&id).map_err(|_| JobsError::Name(name.to_string()))
|
||||
}
|
||||
|
||||
pub fn load(dir: &Path) -> Result<Vec<Job>, JobsError> {
|
||||
let mut entries: Vec<PathBuf> = match std::fs::read_dir(dir) {
|
||||
Ok(entries) => {
|
||||
let mut names = Vec::new();
|
||||
for entry in entries {
|
||||
let entry = entry.map_err(|e| invalid(dir, e))?;
|
||||
names.push(entry.path());
|
||||
}
|
||||
names
|
||||
}
|
||||
Err(e) if e.kind() == ErrorKind::NotFound => return Ok(Vec::new()),
|
||||
Err(e) => return Err(invalid(dir, e)),
|
||||
};
|
||||
entries.sort();
|
||||
|
||||
let mut jobs = Vec::new();
|
||||
for path in entries {
|
||||
let file_name = match path.file_name().and_then(|n| n.to_str()) {
|
||||
Some(name) => name,
|
||||
None => return Err(invalid(&path, "the job file name is not UTF-8")),
|
||||
};
|
||||
let Some(stem) = file_name.strip_suffix(".toml") else {
|
||||
continue;
|
||||
};
|
||||
if stem.is_empty() || !stem.bytes().all(is_job_name_byte) {
|
||||
return Err(JobsError::Name(stem.to_string()));
|
||||
}
|
||||
let content = std::fs::read_to_string(&path).map_err(|e| invalid(&path, e))?;
|
||||
let file: File = toml::from_str(&content).map_err(|e| invalid(&path, e))?;
|
||||
jobs.push(Job {
|
||||
name: stem.to_string(),
|
||||
schedule: file.schedule,
|
||||
prompt: file.prompt,
|
||||
});
|
||||
}
|
||||
Ok(jobs)
|
||||
}
|
||||
|
||||
fn is_job_name_byte(b: u8) -> bool {
|
||||
matches!(b, b'a'..=b'z' | b'0'..=b'9' | b'-')
|
||||
}
|
||||
|
||||
pub fn append(path: &Path, run: &Run) -> Result<(), JobsError> {
|
||||
if let Some(parent) = path.parent()
|
||||
&& !parent.as_os_str().is_empty()
|
||||
{
|
||||
std::fs::create_dir_all(parent).map_err(|e| invalid(parent, e))?;
|
||||
}
|
||||
let line = serde_json::to_string(run).map_err(|e| invalid(path, e))?;
|
||||
let mut file = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(path)
|
||||
.map_err(|e| invalid(path, e))?;
|
||||
file.write_all(line.as_bytes())
|
||||
.map_err(|e| invalid(path, e))?;
|
||||
file.write_all(b"\n").map_err(|e| invalid(path, e))?;
|
||||
file.sync_all().map_err(|e| invalid(path, e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn live_session(path: &Path, name: &str) -> Result<Option<SessionId>, JobsError> {
|
||||
let content = read_ledger(path)?;
|
||||
let mut last: Option<Run> = None;
|
||||
for line in content.lines() {
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let run: Run = serde_json::from_str(line).map_err(|e| invalid(path, e))?;
|
||||
if run.name == name {
|
||||
last = Some(run);
|
||||
}
|
||||
}
|
||||
match last {
|
||||
Some(run) if run.status == RunStatus::Started => Ok(Some(run.session)),
|
||||
_ => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn close_leftovers(path: &Path, now: Timestamp) -> Result<usize, JobsError> {
|
||||
let content = read_ledger(path)?;
|
||||
let mut last_by_name: BTreeMap<String, Run> = BTreeMap::new();
|
||||
for line in content.lines() {
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let run: Run = serde_json::from_str(line).map_err(|e| invalid(path, e))?;
|
||||
last_by_name.insert(run.name.clone(), run);
|
||||
}
|
||||
let mut closed = 0;
|
||||
for run in last_by_name.values() {
|
||||
if run.status == RunStatus::Started {
|
||||
append(
|
||||
path,
|
||||
&Run {
|
||||
time: now,
|
||||
name: run.name.clone(),
|
||||
session: run.session.clone(),
|
||||
status: RunStatus::Failed,
|
||||
},
|
||||
)?;
|
||||
closed += 1;
|
||||
}
|
||||
}
|
||||
Ok(closed)
|
||||
}
|
||||
|
||||
fn read_ledger(path: &Path) -> Result<String, JobsError> {
|
||||
match std::fs::read_to_string(path) {
|
||||
Ok(content) => Ok(content),
|
||||
Err(e) if e.kind() == ErrorKind::NotFound => Ok(String::new()),
|
||||
Err(e) => Err(invalid(path, e)),
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ pub mod compact;
|
||||
pub mod config;
|
||||
pub mod cron;
|
||||
pub mod http;
|
||||
pub mod jobs;
|
||||
pub mod llama;
|
||||
pub mod memory;
|
||||
pub mod selftest;
|
||||
|
||||
@@ -12,6 +12,7 @@ use bxcore::instance;
|
||||
use loopd::broker_port::{BrokerPort, NoBroker, not_configured_line};
|
||||
use loopd::channel::{self, Context};
|
||||
use loopd::config::Config;
|
||||
use loopd::jobs;
|
||||
use loopd::llama::Client;
|
||||
use loopd::memory::{MEMORY_WAIT, MemoryPort};
|
||||
use loopd::selftest::{SelfTestError, run};
|
||||
@@ -84,6 +85,11 @@ fn run_serve(path: &Path) -> ExitCode {
|
||||
return ExitCode::from(1);
|
||||
}
|
||||
|
||||
if let Err(e) = jobs::load(&cfg.paths.home.join("jobs")) {
|
||||
eprintln!("loopd: {e}");
|
||||
return ExitCode::from(1);
|
||||
}
|
||||
|
||||
// One loopd per home (M5b spec, section 9a). The lock comes before the socket is touched and
|
||||
// before the self-test, so a second copy neither takes the socket of the one that runs nor
|
||||
// sends its slot a prompt. Held until the process ends.
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
//! Tests for job files and the run ledger. Do not edit.
|
||||
|
||||
use bxcore::{SessionId, Timestamp};
|
||||
use loopd::jobs::{self, JobsError, RunStatus};
|
||||
|
||||
fn ts() -> Timestamp {
|
||||
Timestamp::parse("2026-10-03T07:00:00.000Z").unwrap()
|
||||
}
|
||||
|
||||
struct Scratch {
|
||||
dir: std::path::PathBuf,
|
||||
}
|
||||
|
||||
impl Scratch {
|
||||
fn new() -> Scratch {
|
||||
let n = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
let dir = std::env::temp_dir().join(format!("loopd-jobs-{}-{n}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
Scratch { dir }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Scratch {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.dir);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_valid_job_file_loads() {
|
||||
let home = Scratch::new();
|
||||
let jobs = home.dir.join("jobs");
|
||||
std::fs::create_dir(&jobs).unwrap();
|
||||
std::fs::write(
|
||||
jobs.join("mail.toml"),
|
||||
"schedule = \"0 7 * * *\"\nprompt = \"Check mail.\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
let loaded = jobs::load(&jobs).unwrap();
|
||||
assert_eq!(loaded.len(), 1);
|
||||
assert_eq!(loaded[0].name, "mail");
|
||||
assert_eq!(loaded[0].schedule, "0 7 * * *");
|
||||
assert_eq!(loaded[0].prompt, "Check mail.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_keys_are_errors_with_the_runbook() {
|
||||
let home = Scratch::new();
|
||||
let jobs = home.dir.join("jobs");
|
||||
std::fs::create_dir(&jobs).unwrap();
|
||||
std::fs::write(
|
||||
jobs.join("mail.toml"),
|
||||
"schedule = \"* * * * *\"\nprompt = \"x\"\nwhen = \"now\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
let err = jobs::load(&jobs).unwrap_err();
|
||||
let s = err.to_string();
|
||||
assert!(s.contains("mail.toml"), "{s}");
|
||||
assert!(s.ends_with("see docs/runbook.md#jobs-invalid"), "{s}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bad_name_is_an_error() {
|
||||
let home = Scratch::new();
|
||||
let jobs = home.dir.join("jobs");
|
||||
std::fs::create_dir(&jobs).unwrap();
|
||||
std::fs::write(
|
||||
jobs.join("Mail.toml"),
|
||||
"schedule = \"* * * * *\"\nprompt = \"x\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
let err = jobs::load(&jobs).unwrap_err();
|
||||
assert!(matches!(err, JobsError::Name(_)), "{err:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_id_fits_and_is_lowercase() {
|
||||
let id = jobs::session_id("mail", ts()).unwrap();
|
||||
assert_eq!(id.as_str(), "job-mail-20261003t0700");
|
||||
SessionId::new(id.as_str()).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_name_too_long_for_a_session_id_is_an_error() {
|
||||
let long = "n".repeat(50);
|
||||
let err = jobs::session_id(&long, ts()).unwrap_err();
|
||||
assert!(matches!(err, JobsError::Name(_)), "{err:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_jobs_directory_is_empty() {
|
||||
let home = Scratch::new();
|
||||
let loaded = jobs::load(&home.dir.join("jobs")).unwrap();
|
||||
assert!(loaded.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leftover_started_is_closed_as_failed() {
|
||||
let home = Scratch::new();
|
||||
let path = home.dir.join("runs.jsonl");
|
||||
let session = jobs::session_id("mail", ts()).unwrap();
|
||||
jobs::append(
|
||||
&path,
|
||||
&jobs::Run {
|
||||
time: ts(),
|
||||
name: "mail".to_string(),
|
||||
session: session.clone(),
|
||||
status: RunStatus::Started,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let n = jobs::close_leftovers(&path, ts()).unwrap();
|
||||
assert_eq!(n, 1);
|
||||
assert!(jobs::live_session(&path, "mail").unwrap().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_started_run_without_a_close_is_live() {
|
||||
let home = Scratch::new();
|
||||
let path = home.dir.join("runs.jsonl");
|
||||
let session = jobs::session_id("mail", ts()).unwrap();
|
||||
jobs::append(
|
||||
&path,
|
||||
&jobs::Run {
|
||||
time: ts(),
|
||||
name: "mail".to_string(),
|
||||
session: session.clone(),
|
||||
status: RunStatus::Started,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
jobs::live_session(&path, "mail").unwrap().as_ref(),
|
||||
Some(&session)
|
||||
);
|
||||
jobs::append(
|
||||
&path,
|
||||
&jobs::Run {
|
||||
time: ts(),
|
||||
name: "mail".to_string(),
|
||||
session,
|
||||
status: RunStatus::Finished,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert!(jobs::live_session(&path, "mail").unwrap().is_none());
|
||||
}
|
||||
Reference in New Issue
Block a user