# 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/.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--t` 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 test `crates/loopd/tests/jobs.rs`. - Append, from `docs/plans/M5d2/files/runbook-jobs-invalid.md`, to `docs/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 - **`name` from the file stem.** `mail.toml` → `mail`. The stem must match `^[a-z0-9-]+$` (bytes, nonempty) and `session_id(name, now)` must succeed. `Mail.toml` is `JobsError::Name`. - **`session_id`.** `format!("job-{name}-{}t{}", ymd, hm)` with `ymd` = `rfc[0..4]+rfc[5..7]+rfc[8..10]` and `hm` = `rfc[11..13]+rfc[14..16]`, all from `now.to_rfc3339()`, then `SessionId::new`. `SessionId` is `[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 is `JobsError::Invalid` naming the directory. Skip names that do not end in `.toml`. Sort by `name` so the order is stable. Parse with `#[serde(deny_unknown_fields)]` on a private `File { 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 last `Run` whose `name` matches: `Started` → `Some(session)`, anything else → `None`. - **`close_leftovers`.** Names whose last run is `Started` get one more `Run` with `status: Failed` and the same `session`, `time: now`. Return how many names were closed. A missing file is `Ok(0)`. - **`Display` for `Invalid`.** `"{path}: {detail}; see docs/runbook.md#jobs-invalid"`. The given unknown-keys test checks `ends_with("see docs/runbook.md#jobs-invalid")` and that the path's file name is in the string. - **`run_serve`.** After `check_summarise`, `jobs::load(&cfg.paths.home.join("jobs"))`. On `Err`, print `loopd: {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_str` | already a `loopd` dependency | | `SessionId::new` | the 64-byte alphabet check | | `serde` `deny_unknown_fields` | unknown keys | ## Skeleton `crates/loopd/src/jobs.rs` — fill every function; no `todo!()`. Keep the types in this order. ```rust //! 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 { // Build job--t 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, 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, JobsError> { let _ = (path, name); Ok(None) } pub fn close_leftovers(path: &Path, now: Timestamp) -> Result { 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.** ```sh 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 target `jobs` does not compile (`E0432` unresolved `loopd::jobs`). Every other target compiles. - [ ] **3. The module.** Write `jobs.rs` from the skeleton, then fill the four bodies one at a time (`session_id`, `load`, `append`, `live_session` / `close_leftovers`). Add `pub mod jobs;` to `lib.rs`. Wire `run_serve` as in Facts. `cargo check -p loopd` prints no error and no warning. `wc -l crates/loopd/src/jobs.rs` prints a number ≤ 500. - [ ] **4. The tests.** `cargo test -p loopd --test jobs`: every test passes, 8 tests. - [ ] **5. The runbook.** From the repository root: ```sh { echo; cat docs/plans/M5d2/files/runbook-jobs-invalid.md; } >> docs/runbook.md ``` Then `grep -c '^## jobs-invalid$' docs/runbook.md` prints `1`. - [ ] **6. Check.** `git diff --stat` lists `crates/loopd/src/jobs.rs`, `crates/loopd/src/lib.rs`, `crates/loopd/src/main.rs`, `docs/runbook.md`. - [ ] **7. Run the gate.** `cargo fmt --all`, then `make gate`, timeout 600000 ms. Last line `gate: ok`. Test count **1545** (1537 before, plus 8). After `gate: ok`, do not add up `test result:` lines. - [ ] **8. Log and commit.** Task cell `M5d2/05-loopd-jobs`. Stage with `git add crates/loopd docs/runbook.md docs/implementer-log.md Cargo.lock`, then commit as `AGENTS.md` says. ## Done when - `make gate` prints `gate: ok` with 1545 tests. - `git diff HEAD~1 --stat` lists only `crates/loopd/src/jobs.rs`, `crates/loopd/src/lib.rs`, `crates/loopd/src/main.rs`, `crates/loopd/tests/jobs.rs`, `docs/runbook.md` and the log. ## Stop and report if - The branch check fails; step 2 differs; `cargo check` warns; a test fails; the gate's last line is not `gate: ok`; you add a cron crate; a missing jobs directory is an error; `Display` for `Invalid` does not end with the runbook pointer; `jobs.rs` exceeds 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`.