session_slot and the scheduler tests held a temporary JSON Value across statements (E0716). Cron now pins DOM and DOW as AND, which the given tests had not. Gate counts from task 04 on move up by one. Co-Authored-By: Grok 4.6 <noreply@x.ai>
8.5 KiB
M5d-2 task 05: job files and the run ledger
Branch: m5d2. Check it: git branch --show-current prints m5d2 and git status --short
is empty; otherwise stop. Never switch branch.
Commit subject: loopd: job files, run ledger, jobs-invalid
Log row: the Task cell is M5d2/05-loopd-jobs.
Goal
Owner-written {home}/jobs/<name>.toml and a run ledger {home}/jobs/runs.jsonl (spec 4.2).
loopd reads job files and cannot write them. Unknown keys, a name that is not [a-z0-9-]+,
or a name so long that job-<name>-<yyyymmdd>t<hhmm> is not a SessionId, are
jobs-invalid. A missing jobs directory is an empty list.
Nothing ticks yet. loopd serve refuses to start when load errors, so a misspelt job cannot
sit silently.
Every place that changes
| Place | What happens there |
|---|---|
crates/loopd/src/jobs.rs |
new: Job, Run, RunStatus, load, session_id, append, close_leftovers, live_session |
crates/loopd/src/lib.rs |
pub mod jobs; |
crates/loopd/src/main.rs |
run_serve calls jobs::load after check_summarise |
docs/runbook.md |
append the given jobs-invalid entry |
crates/loopd/tests/jobs.rs |
new, given |
| everything else | no change |
Files
- Copy, from
docs/plans/M5d2/files/(the same path under it): the new testcrates/loopd/tests/jobs.rs. - Append, from
docs/plans/M5d2/files/runbook-jobs-invalid.md, todocs/runbook.md. - Create:
crates/loopd/src/jobs.rs(skeleton below). - Modify:
crates/loopd/src/lib.rs,crates/loopd/src/main.rs,docs/implementer-log.md. - Do not touch: any other file.
Facts you need
namefrom the file stem.mail.toml→mail. The stem must match^[a-z0-9-]+$(bytes, nonempty) andsession_id(name, now)must succeed.Mail.tomlisJobsError::Name.session_id.format!("job-{name}-{}t{}", ymd, hm)withymd=rfc[0..4]+rfc[5..7]+rfc[8..10]andhm=rfc[11..13]+rfc[14..16], all fromnow.to_rfc3339(), thenSessionId::new.SessionIdis[a-z0-9-], max 64."n".repeat(50)is too long (job-+ 50 +-+ 8 +t+ 4 = 68).load.read_dir.ErrorKind::NotFound→Ok(vec![]). Any other IO error isJobsError::Invalidnaming the directory. Skip names that do not end in.toml. Sort bynameso the order is stable. Parse with#[serde(deny_unknown_fields)]on a privateFile { schedule, prompt }.RunStatus.started/finished/failed(rename_all = "snake_case").append. Create the parent directory (0700 is fine; do not weaken a tighter mode). One JSON object, then a newline. Open append, write,sync_all.live_session. Walk every line of the ledger (missing file →Ok(None)). The lastRunwhosenamematches:Started→Some(session), anything else →None.close_leftovers. Names whose last run isStartedget one moreRunwithstatus: Failedand the samesession,time: now. Return how many names were closed. A missing file isOk(0).DisplayforInvalid."{path}: {detail}; see docs/runbook.md#jobs-invalid". The given unknown-keys test checksends_with("see docs/runbook.md#jobs-invalid")and that the path's file name is in the string.run_serve. Aftercheck_summarise,jobs::load(&cfg.paths.home.join("jobs")). OnErr, printloopd: {e}and return exit 1. Do not create the directory here.- A file that exists but cannot be read is an error. Only a missing directory is empty.
- The items the edits use. This table is complete.
Item What it does toml::from_stralready a loopddependencySessionId::newthe 64-byte alphabet check serdedeny_unknown_fieldsunknown keys
Skeleton
crates/loopd/src/jobs.rs — fill every function; no todo!(). Keep the types in this order.
//! Owner-written job files and the run ledger (M5d spec, section 4.2).
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,
}
pub fn session_id(name: &str, now: Timestamp) -> Result<SessionId, JobsError> {
// Build job-<name>-<yyyymmdd>t<hhmm> from now.to_rfc3339() as in Facts. SessionId::new
// maps a failure to JobsError::Name(name.to_string()).
let _ = (name, now);
Err(JobsError::Name(name.to_string()))
}
pub fn load(dir: &Path) -> Result<Vec<Job>, JobsError> {
let _ = dir;
Ok(Vec::new())
}
pub fn append(path: &Path, run: &Run) -> Result<(), JobsError> {
let _ = (path, run);
Ok(())
}
pub fn live_session(path: &Path, name: &str) -> Result<Option<SessionId>, JobsError> {
let _ = (path, name);
Ok(None)
}
pub fn close_leftovers(path: &Path, now: Timestamp) -> Result<usize, JobsError> {
let _ = (path, now);
Ok(0)
}
Replace the four stub bodies in the same task. Do not leave a stub that returns Ok(None) /
Ok(0) / Ok(vec![]) once you have written the real body. session_id must not stay on the
Err path.
Steps
-
1. Copy the given test.
cp docs/plans/M5d2/files/crates/loopd/tests/jobs.rs crates/loopd/tests/ -
2. See it fail.
cargo build --tests --keep-going -p loopd, timeout 600000 ms: test targetjobsdoes not compile (E0432unresolvedloopd::jobs). Every other target compiles. -
3. The module. Write
jobs.rsfrom the skeleton, then fill the four bodies one at a time (session_id,load,append,live_session/close_leftovers). Addpub mod jobs;tolib.rs. Wirerun_serveas in Facts.cargo check -p loopdprints no error and no warning.wc -l crates/loopd/src/jobs.rsprints a number ≤ 500. -
4. The tests.
cargo test -p loopd --test jobs: every test passes, 8 tests. -
5. The runbook. From the repository root:
{ echo; cat docs/plans/M5d2/files/runbook-jobs-invalid.md; } >> docs/runbook.mdThen
grep -c '^## jobs-invalid$' docs/runbook.mdprints1. -
6. Check.
git diff --statlistscrates/loopd/src/jobs.rs,crates/loopd/src/lib.rs,crates/loopd/src/main.rs,docs/runbook.md. -
7. Run the gate.
cargo fmt --all, thenmake gate, timeout 600000 ms. Last linegate: ok. Test count 1545 (1537 before, plus 8). -
8. Log and commit. Task cell
M5d2/05-loopd-jobs. Stage withgit add crates/loopd docs/runbook.md docs/implementer-log.md Cargo.lock, then commit asAGENTS.mdsays.
Done when
make gateprintsgate: okwith 1545 tests.git diff HEAD~1 --statlists onlycrates/loopd/src/jobs.rs,crates/loopd/src/lib.rs,crates/loopd/src/main.rs,crates/loopd/tests/jobs.rs,docs/runbook.mdand the log.
Stop and report if
- The branch check fails; step 2 differs;
cargo checkwarns; a test fails; the gate count is not 1545; you add a cron crate; a missing jobs directory is an error;DisplayforInvaliddoes not end with the runbook pointer;jobs.rsexceeds 500 lines; you write a job file.
What the given tests pin
A valid mail.toml loads; unknown keys name the file and the runbook; Mail.toml is Name;
session_id is job-mail-20261003t0700; a 50-character name is Name; a missing directory is
empty; leftover started becomes failed and is no longer live; a started without a close
is live until finished.