loopd: job files, run ledger, jobs-invalid

Co-Authored-By: ornith-1.5-35b-a3b <agent@wntrmute.dev>
This commit is contained in:
2026-10-03 14:20:19 -07:00
co-authored by ornith-1.5-35b-a3b
parent 587b3924d7
commit 670b5bbabe
6 changed files with 377 additions and 0 deletions
+193
View File
@@ -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)),
}
}
+1
View File
@@ -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;
+6
View File
@@ -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.
+151
View File
@@ -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());
}